- Published on
State Management Complete Comparison 2025: React Context vs Zustand vs Jotai vs Redux Toolkit vs Recoil
- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 1. The 2025 State Management Landscape
- 2. The Server State Revolution: TanStack Query and SWR
- 3. React Context: When Is It Enough?
- 4. Redux Toolkit: Is It Still Necessary?
- 5. Zustand: The Triumph of Minimalism
- 6. Jotai: The Power of Atomic State
- 7. Recoil: Meta's Choice, but in Decline
- 8. Signals: A New Paradigm
- 9. Comprehensive Comparison Table
- 10. Project Selection Guide
- 11. Migration Guide
- 12. Interview Questions and Quiz
- References
1. The 2025 State Management Landscape
npm Download Trends
Competition among state management libraries in the React ecosystem has never been fiercer. Here is the weekly download breakdown for 2025:
| Library | Weekly Downloads (2025) | Bundle Size | First Release |
|---|---|---|---|
| Redux (+ Toolkit) | ~9M | 11KB (RTK) | 2015 |
| Zustand | ~5.5M | 1.1KB | 2019 |
| TanStack Query | ~4.5M | 13KB | 2020 |
| Jotai | ~2.2M | 3.4KB | 2020 |
| Recoil | ~500K | 21KB | 2020 |
The Paradigm Shift
The most important change in 2025 is the separation of server state and client state. In the past, Redux handled everything. Now, roles are clearly divided:
- Server State: TanStack Query / SWR (API data, caching, synchronization)
- Client State: Zustand / Jotai / Redux Toolkit (UI state, form state, theme)
- URL State: nuqs / next-usequerystate (search filters, pagination)
- Form State: React Hook Form / Formik (input values, validation)
┌─────────────────────────────────────────────────┐
│ Application State │
├──────────────┬──────────────┬───────────────────┤
│ Server State │ Client State │ URL / Form State │
│ TanStack Q │ Zustand │ nuqs + RHF │
│ SWR │ Jotai │ │
│ │ Redux TK │ │
└──────────────┴──────────────┴───────────────────┘
2. The Server State Revolution: TanStack Query and SWR
Why Separate Server State?
In the traditional Redux pattern, API call results were stored in the global store. This approach comes with serious problems:
- Cache invalidation: You must manually manage when to re-fetch data
- Loading/Error states: You must manually handle isLoading and error states every time
- Duplicate requests: Multiple components requesting the same data trigger duplicate API calls
- Optimistic updates: Implementation is extremely complex
TanStack Query (React Query v5)
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
// Server state fetching - automatic caching and revalidation
function useUsers() {
return useQuery({
queryKey: ['users'],
queryFn: () => fetch('/api/users').then(res => res.json()),
staleTime: 5 * 60 * 1000, // Stay fresh for 5 minutes
gcTime: 30 * 60 * 1000, // Garbage collect after 30 minutes
})
}
// Server state mutation - with optimistic update
function useCreateUser() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (newUser: User) =>
fetch('/api/users', {
method: 'POST',
body: JSON.stringify(newUser),
}).then(res => res.json()),
// Optimistic update
onMutate: async (newUser) => {
await queryClient.cancelQueries({ queryKey: ['users'] })
const previous = queryClient.getQueryData(['users'])
queryClient.setQueryData(['users'], (old: User[]) => [...old, newUser])
return { previous }
},
onError: (err, newUser, context) => {
queryClient.setQueryData(['users'], context?.previous)
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['users'] })
},
})
}
The Stale-While-Revalidate Pattern
User request → Check cache → Return cached data immediately (stale)
↓
Fetch new data in background (revalidate)
↓
Update UI with fresh data
Thanks to this pattern, users always get an instant response while automatically receiving the latest data.
SWR vs TanStack Query
| Feature | TanStack Query v5 | SWR v2 |
|---|---|---|
| Bundle size | 13KB | 4.2KB |
| Mutation support | Built-in useMutation | Manual implementation |
| Optimistic updates | First-class support | Manual implementation |
| Devtools | Dedicated DevTools | None |
| Infinite scrolling | useInfiniteQuery | useSWRInfinite |
| SSR support | Hydration API | Next.js integration |
3. React Context: When Is It Enough?
When Context Is Appropriate
React Context is a built-in state sharing mechanism that requires no additional library.
// Suitable for infrequently changing state: theme, auth, locale
const ThemeContext = createContext<ThemeContextType | null>(null)
function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<'light' | 'dark'>('light')
const value = useMemo(() => ({ theme, setTheme }), [theme])
return (
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
)
}
function useTheme() {
const context = useContext(ThemeContext)
if (!context) throw new Error('useTheme must be used within ThemeProvider')
return context
}
The Performance Trap of Context
The biggest problem with Context is that when the value changes, every consumer re-renders.
// Bad pattern: All state in a single Context
const AppContext = createContext({
user: null,
theme: 'light',
notifications: [],
sidebar: false,
})
// Problem: Changing just sidebar causes ALL components
// using user, theme, or notifications to re-render!
Solving It with the Composition Pattern
// Good pattern: Separate Context per state concern
function App() {
return (
<ThemeProvider>
<AuthProvider>
<NotificationProvider>
<SidebarProvider>
<Layout />
</SidebarProvider>
</NotificationProvider>
</AuthProvider>
</ThemeProvider>
)
}
// Each Context manages only its own state,
// preventing unnecessary re-renders
When Context Is Not Enough
A dedicated state management library is needed when:
- State changes frequently (counters, timers, real-time data)
- You need fine-grained subscription to specific state slices
- Middleware is required (logging, persistence, dev tools)
- State change logic is complex
4. Redux Toolkit: Is It Still Necessary?
RTK's Current Position
Redux Toolkit (RTK) solved Redux's boilerplate problem and became the official recommendation. In 2025, it remains a strong choice for large-scale enterprise applications.
createSlice: Eliminating Boilerplate
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
interface TodoState {
items: Todo[]
filter: 'all' | 'active' | 'completed'
}
const todoSlice = createSlice({
name: 'todos',
initialState: { items: [], filter: 'all' } as TodoState,
reducers: {
addTodo: (state, action: PayloadAction<string>) => {
// Immer is built in - no need to worry about immutability
state.items.push({
id: crypto.randomUUID(),
text: action.payload,
completed: false,
})
},
toggleTodo: (state, action: PayloadAction<string>) => {
const todo = state.items.find(t => t.id === action.payload)
if (todo) todo.completed = !todo.completed
},
setFilter: (state, action: PayloadAction<TodoState['filter']>) => {
state.filter = action.payload
},
},
})
export const { addTodo, toggleTodo, setFilter } = todoSlice.actions
export default todoSlice.reducer
RTK Query: Built-in Server State Management
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
const api = createApi({
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
tagTypes: ['User', 'Post'],
endpoints: (builder) => ({
getUsers: builder.query<User[], void>({
query: () => '/users',
providesTags: ['User'],
}),
createUser: builder.mutation<User, Partial<User>>({
query: (body) => ({
url: '/users',
method: 'POST',
body,
}),
invalidatesTags: ['User'],
}),
}),
})
export const { useGetUsersQuery, useCreateUserMutation } = api
When Redux Toolkit Is Still Necessary
- Large teams: Teams that need strict patterns and conventions
- Complex business logic: Complex interactions among multiple states
- Time-travel debugging: Powerful debugging with Redux DevTools
- Middleware ecosystem: Rich middleware support including saga, thunk
- Legacy compatibility: Gradual migration from existing Redux codebases
5. Zustand: The Triumph of Minimalism
Why Zustand?
Zustand is the lightest state management library at a remarkable 1.1KB bundle size. It requires no Provider, has minimal boilerplate, and works perfectly with TypeScript.
Basic Store Pattern
import { create } from 'zustand'
interface BearStore {
bears: number
increase: () => void
decrease: () => void
reset: () => void
}
const useBearStore = create<BearStore>((set) => ({
bears: 0,
increase: () => set((state) => ({ bears: state.bears + 1 })),
decrease: () => set((state) => ({ bears: state.bears - 1 })),
reset: () => set({ bears: 0 }),
}))
// Usage: Call anywhere without a Provider
function BearCounter() {
const bears = useBearStore((state) => state.bears)
return <span>{bears} bears</span>
}
function BearControls() {
const increase = useBearStore((state) => state.increase)
return <button onClick={increase}>Add bear</button>
}
Middleware: persist, devtools, immer
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
import { immer } from 'zustand/middleware/immer'
interface AppStore {
user: User | null
preferences: Preferences
setUser: (user: User | null) => void
updatePreference: (key: string, value: unknown) => void
}
const useAppStore = create<AppStore>()(
devtools(
persist(
immer((set) => ({
user: null,
preferences: { theme: 'light', language: 'en' },
setUser: (user) => set((state) => {
state.user = user
}),
updatePreference: (key, value) => set((state) => {
state.preferences[key] = value
}),
})),
{
name: 'app-storage', // localStorage key
partialize: (state) => ({
preferences: state.preferences,
}),
}
),
{ name: 'AppStore' } // DevTools label
)
)
Slice Pattern: Splitting Large Stores
// userSlice.ts
const createUserSlice = (set: SetState, get: GetState) => ({
user: null,
login: async (credentials: Credentials) => {
const user = await authApi.login(credentials)
set({ user })
},
logout: () => set({ user: null }),
})
// cartSlice.ts
const createCartSlice = (set: SetState, get: GetState) => ({
items: [],
addItem: (item: CartItem) =>
set((state) => ({ items: [...state.items, item] })),
totalPrice: () =>
get().items.reduce((sum, item) => sum + item.price, 0),
})
// Combine
const useStore = create((...args) => ({
...createUserSlice(...args),
...createCartSlice(...args),
}))
Zustand's Strengths
- 1.1KB bundle size (gzipped)
- No Provider needed: Use anywhere in the component tree
- Selective subscription: Subscribe to only what you need with
(state) => state.bears - Works outside React: Also works in vanilla JS
- SSR compatible: Perfect compatibility with Next.js
6. Jotai: The Power of Atomic State
Atom-based Design
Jotai manages state in "atom" units. Each atom is independent, and only the components that subscribe to it re-render.
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai'
// Basic atoms
const countAtom = atom(0)
const nameAtom = atom('Guest')
// Derived atom
const doubleCountAtom = atom((get) => get(countAtom) * 2)
// Read/Write atom
const countWithLogAtom = atom(
(get) => get(countAtom),
(get, set, newValue: number) => {
console.log(`Count changed: ${get(countAtom)} -> ${newValue}`)
set(countAtom, newValue)
}
)
// Usage
function Counter() {
const [count, setCount] = useAtom(countAtom)
const doubleCount = useAtomValue(doubleCountAtom)
return (
<div>
<p>Count: {count}, Double: {doubleCount}</p>
<button onClick={() => setCount(c => c + 1)}>+1</button>
</div>
)
}
Async Atoms and Suspense Integration
// Async atom - automatic React Suspense integration
const userAtom = atom(async () => {
const response = await fetch('/api/user')
return response.json()
})
// Async atom with dependencies
const userPostsAtom = atom(async (get) => {
const user = await get(userAtom)
const response = await fetch(`/api/users/${user.id}/posts`)
return response.json()
})
// Loading handled via Suspense
function UserPosts() {
const posts = useAtomValue(userPostsAtom)
return (
<ul>
{posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)
}
function App() {
return (
<Suspense fallback={<Spinner />}>
<UserPosts />
</Suspense>
)
}
atomFamily: Dynamic Atom Creation
import { atomFamily } from 'jotai/utils'
// Create independent atoms per ID
const todoAtomFamily = atomFamily((id: string) =>
atom({
id,
text: '',
completed: false,
})
)
// Each Todo is managed by an independent atom,
// so changing one Todo does not cause others to re-render
function TodoItem({ id }: { id: string }) {
const [todo, setTodo] = useAtom(todoAtomFamily(id))
return (
<label>
<input
type="checkbox"
checked={todo.completed}
onChange={() => setTodo(prev => ({ ...prev, completed: !prev.completed }))}
/>
{todo.text}
</label>
)
}
Jotai's Strengths
- 3.4KB bundle size (gzipped)
- Atomic re-rendering: Only components subscribed to the changed atom re-render
- React Suspense integration: Handle async state declaratively
- Bottom-up approach: Compose state from small units
- DevTools support: Jotai DevTools, React DevTools integration
7. Recoil: Meta's Choice, but in Decline
Recoil's Current Status
Recoil is an atomic state management library created by Meta (Facebook). However, as of 2025, maintenance has been sluggish and release intervals have widened.
Basic Usage
import { atom, selector, useRecoilState, useRecoilValue } from 'recoil'
const todoListAtom = atom({
key: 'todoList',
default: [] as Todo[],
})
const filteredTodoSelector = selector({
key: 'filteredTodos',
get: ({ get }) => {
const list = get(todoListAtom)
const filter = get(todoFilterAtom)
switch (filter) {
case 'completed': return list.filter(t => t.completed)
case 'active': return list.filter(t => !t.completed)
default: return list
}
},
})
// atomFamily - dynamic atoms
const userAtomFamily = atomFamily({
key: 'user',
default: (userId: string) => async () => {
const res = await fetch(`/api/users/${userId}`)
return res.json()
},
})
Recoil vs Jotai
| Criteria | Recoil | Jotai |
|---|---|---|
| Bundle size | 21KB | 3.4KB |
| Key required | Mandatory (string key) | Not required |
| Provider | RecoilRoot required | Optional |
| Maintenance | Sluggish | Active |
| SSR | Limited | Full support |
| Community | Declining | Growing |
Since Jotai provides all of Recoil's features in a smaller bundle with active maintenance, Jotai is recommended for new projects.
8. Signals: A New Paradigm
What Are Signals?
Signals are state primitives that provide fine-grained reactivity. Unlike React's re-rendering model, when a Signal changes, only the DOM parts that use that Signal are updated directly.
Preact Signals
import { signal, computed, effect } from '@preact/signals-react'
// Create signals
const count = signal(0)
const doubled = computed(() => count.value * 2)
// Use in components - direct DOM update without re-rendering
function Counter() {
return (
<div>
<p>Count: {count}</p>
<p>Doubled: {doubled}</p>
<button onClick={() => count.value++}>+1</button>
</div>
)
}
// Side effects
effect(() => {
console.log(`Count is now: ${count.value}`)
})
SolidJS Signals
import { createSignal, createMemo, createEffect } from 'solid-js'
function Counter() {
const [count, setCount] = createSignal(0)
const doubled = createMemo(() => count() * 2)
createEffect(() => {
console.log(`Count: ${count()}`)
})
return (
<div>
<p>Count: {count()}, Doubled: {doubled()}</p>
<button onClick={() => setCount(c => c + 1)}>+1</button>
</div>
)
}
Angular Signals (v17+)
import { signal, computed, effect } from '@angular/core'
@Component({
template: `
<p>Count: {{ count() }}, Doubled: {{ doubled() }}</p>
<button (click)="increment()">+1</button>
`
})
class CounterComponent {
count = signal(0)
doubled = computed(() => this.count() * 2)
constructor() {
effect(() => {
console.log(`Count: ${this.count()}`)
})
}
increment() {
this.count.update(c => c + 1)
}
}
React and Signals
The React team has not officially adopted Signals. Instead, React 19's compiler (React Forget) automatically performs similar optimizations. In other words, React chose compiler-based optimization over Signals.
| Approach | Frameworks | Principle |
|---|---|---|
| Signals | Preact, Solid, Angular | Fine-grained reactivity tracking at runtime |
| Compiler | React 19 | Automatic memoization at build time |
| Proxy | MobX, Valtio | Change detection via Proxy objects |
9. Comprehensive Comparison Table
Feature Comparison
| Criteria | Context | Redux TK | Zustand | Jotai | Recoil |
|---|---|---|---|---|---|
| Bundle size | 0KB (built-in) | 11KB | 1.1KB | 3.4KB | 21KB |
| Boilerplate | Medium | Low (RTK) | Very low | Very low | Medium |
| Learning curve | Low | Medium | Low | Low | Medium |
| TypeScript | Good | Excellent | Excellent | Excellent | Good |
| SSR support | Good | Good | Excellent | Excellent | Limited |
| DevTools | React DT | Redux DT | Redux DT | Jotai DT | Limited |
| Middleware | None | Rich | persist/devtools | Utilities | None |
| Selective subscription | No | useSelector | selector fn | Per atom | selector |
| Provider required | Yes | Yes | No | Optional | Yes |
| Async support | useEffect | createAsyncThunk | Manual | Suspense | Suspense |
Performance Benchmark (1,000-item List)
| Scenario | Context | Redux TK | Zustand | Jotai |
|---|---|---|---|---|
| Single item update | 12.3ms | 2.1ms | 1.8ms | 0.9ms |
| Full list update | 15.2ms | 8.7ms | 7.9ms | 8.2ms |
| Memory usage | Baseline | +2.1MB | +0.3MB | +0.5MB |
| Initial render | 45ms | 52ms | 44ms | 46ms |
10. Project Selection Guide
Decision Framework
What is the project scale?
├── Small (1-3 people, simple UI)
│ ├── Does state change frequently?
│ │ ├── Yes → Zustand
│ │ └── No → React Context
│ └── Is server data the focus?
│ └── Yes → TanStack Query alone is sufficient
│
├── Medium (4-10 people, complex UI)
│ ├── Are there many independent states?
│ │ ├── Yes → Jotai
│ │ └── No → Zustand
│ └── Is server state complex?
│ └── Yes → TanStack Query + Zustand/Jotai
│
└── Large (10+ people, enterprise)
├── Is there existing Redux code?
│ ├── Yes → Migrate to Redux Toolkit
│ └── No → Zustand + TanStack Query
└── Is strict architecture required?
└── Yes → Redux Toolkit
Recommended Combinations by Scenario
| Scenario | Recommended Combination |
|---|---|
| Simple landing page | React Context |
| SaaS dashboard | Zustand + TanStack Query |
| E-commerce | Jotai + TanStack Query |
| Social media app | Zustand + TanStack Query |
| Large enterprise | Redux Toolkit + RTK Query |
| Real-time collaboration tool | Zustand + WebSocket middleware |
| Editor/IDE-type tool | Jotai (fine-grained state control) |
11. Migration Guide
From Redux to Zustand
// Before: Redux Toolkit
// store.ts
const counterSlice = createSlice({
name: 'counter',
initialState: { value: 0 },
reducers: {
increment: (state) => { state.value += 1 },
decrement: (state) => { state.value -= 1 },
},
})
// Component
function Counter() {
const count = useSelector((state: RootState) => state.counter.value)
const dispatch = useDispatch()
return <button onClick={() => dispatch(increment())}>Count: {count}</button>
}
// After: Zustand
const useCounterStore = create<CounterStore>((set) => ({
value: 0,
increment: () => set((s) => ({ value: s.value + 1 })),
decrement: () => set((s) => ({ value: s.value - 1 })),
}))
function Counter() {
const count = useCounterStore((s) => s.value)
const increment = useCounterStore((s) => s.increment)
return <button onClick={increment}>Count: {count}</button>
}
From Context to Jotai
// Before: React Context
const CountContext = createContext({ count: 0, setCount: () => {} })
function CountProvider({ children }) {
const [count, setCount] = useState(0)
return (
<CountContext.Provider value={{ count, setCount }}>
{children}
</CountContext.Provider>
)
}
function Counter() {
const { count, setCount } = useContext(CountContext)
return <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
}
// After: Jotai
const countAtom = atom(0)
function Counter() {
const [count, setCount] = useAtom(countAtom)
return <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
}
// Provider removed! 50% less code!
Migration Checklist
- Classify existing state into server vs client
- Move server state to TanStack Query
- Move client state to your chosen library
- Update test code
- Compare performance benchmarks
- Gradual rollout (migrate feature by feature)
12. Interview Questions and Quiz
Top 10 Interview Questions
Q1. Explain the difference between server state and client state, and recommend appropriate tools for each.
Server state is asynchronous data fetched from APIs where caching, invalidation, and synchronization are key. TanStack Query or SWR is appropriate. Client state is purely frontend state such as UI toggles and form inputs where Zustand, Jotai, or Context is appropriate.
Q2. What is the performance problem with React Context, and how do you solve it?
When Context's value changes, every component subscribing to that Context re-renders. Solutions include splitting Context, memoizing values with useMemo, or switching to Zustand/Jotai.
Q3. What is the core difference between Zustand and Jotai?
Zustand uses a store-based (top-down) approach that groups related state into a single store. Jotai uses an atom-based (bottom-up) approach that splits state into minimal units. Zustand is better when a single store feels natural; Jotai is better when there are many independent states.
Q4. Explain why Redux Toolkit is still relevant in 2025.
Its strengths include strict pattern enforcement, time-travel debugging, a rich middleware ecosystem, built-in RTK Query, and code consistency in large teams.
Q5. Explain the Stale-While-Revalidate pattern.
Cached (stale) data is returned immediately for a fast UX, while new data is fetched in the background (revalidate) and the UI is updated. This is the core strategy of TanStack Query and SWR.
Q6. How do Signals differ from React's re-rendering model?
React re-executes the entire component function on state change and diffs the Virtual DOM. Signals directly update only the DOM parts that use the changed value, avoiding unnecessary component re-execution.
Q7. What problem does Zustand's persist middleware cause in SSR, and how do you fix it?
localStorage does not exist on the server, causing hydration mismatch. Fix this with the skipHydration option or manual hydration inside useEffect.
Q8. Explain the difference between Jotai's derived atom and Recoil's selector.
They are functionally similar, but Jotai requires no string key and has a bundle size 6x smaller. Jotai atoms are identified by reference equality, eliminating key collision risk.
Q9. What is the queryKey design strategy for TanStack Query?
Use hierarchical key structures (e.g., ['users', userId, 'posts']) to enable fine-grained cache invalidation. Invalidating a parent key automatically invalidates child keys.
Q10. What criteria should you use to choose a state management tool for a new project?
Consider project scale, team experience, SSR requirements, bundle size constraints, and state complexity. Most projects should start with the TanStack Query + Zustand combination.
Quiz (5 Questions)
Q1. Why does Zustand not require a Provider?
Zustand creates stores at module scope as global singletons. Because state is managed outside the React component tree, there is no need to wrap components with a Provider. This simplifies setup and eliminates Provider nesting issues.
Q2. What is the difference between TanStack Query's staleTime and gcTime?
staleTime is how long data remains in a "fresh" state. Fresh data is not re-fetched. gcTime (garbage collection time) is how long inactive query data stays in memory before being removed. Defaults are 0 for staleTime and 5 minutes for gcTime.
Q3. Name two advantages of Jotai's atom over Recoil's atom.
First, no string key is required. Jotai atoms are identified by JavaScript object reference, eliminating key collision risk. Second, the bundle size is 3.4KB, roughly one-sixth of Recoil's 21KB.
Q4. Can useMemo alone fully solve React Context's re-rendering problem?
No. useMemo prevents unnecessary re-creation of the value object, but when actual values inside the object change, all consumers still re-render. You need to split Context or use a library that supports selective subscriptions (Zustand, Jotai).
Q5. What is the most important consideration when migrating from Redux Toolkit to Zustand?
Zustand does not have the same middleware chain concept as Redux, so you must redesign sagas and complex async flows. Additionally, Redux DevTools' time-travel debugging is only partially available in Zustand. A strategy of temporarily running both libraries in parallel is needed for gradual migration.
References
- Zustand GitHub Repository
- Jotai Documentation
- Redux Toolkit Official Docs
- TanStack Query Documentation
- Recoil Documentation
- Preact Signals
- SolidJS Reactivity
- Angular Signals RFC
- SWR Documentation
- React Documentation - Context
- npm trends: State Management Libraries
- Theo Browne - State Management in 2025
- Jack Herrington - Zustand vs Jotai
- TkDodo - Practical React Query