React Context vs Zustand vs Redux — Choosing the Right State Manager
Advertisement
Introduction
Why This Matters
Choosing the wrong state management solution causes either unnecessary complexity (over-engineering with Redux in a small app) or performance problems (using Context for frequently-updated state). The right choice depends on how often state changes, how many components consume it, and how complex your update logic is.
React Context API
Context is built into React — no dependencies required. It is ideal for low-frequency updates like themes, locale settings, and authentication state.
// context/auth-context.tsx
'use client'
import { createContext, useContext, useState, useCallback } from 'react'
type User = { id: string; email: string; role: 'admin' | 'user' }
type AuthContextType = {
user: User | null
login: (email: string, password: string) => Promise<void>
logout: () => void
}
const AuthContext = createContext<AuthContextType | null>(null)
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null)
const login = useCallback(async (email: string, password: string) => {
const response = await fetch('/api/auth/login', {
method: 'POST',
body: JSON.stringify({ email, password })
})
const data = await response.json()
setUser(data.user)
}, [])
const logout = useCallback(() => {
setUser(null)
fetch('/api/auth/logout', { method: 'POST' })
}, [])
return (
<AuthContext.Provider value={{ user, login, logout }}>
{children}
</AuthContext.Provider>
)
}
export function useAuth() {
const ctx = useContext(AuthContext)
if (!ctx) throw new Error('useAuth must be used inside AuthProvider')
return ctx
}The limitation: Every consumer of the context re-renders whenever any value in the context changes. Use separate contexts for unrelated state (one for user, one for theme) to minimize re-renders.
Zustand — Minimal Global State
Zustand is a lightweight (~2KB) store that only re-renders components subscribed to the specific slice of state that changed.
// stores/cart-store.ts
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
type CartItem = { id: string; name: string; price: number; quantity: number }
type CartStore = {
items: CartItem[]
addItem: (item: CartItem) => void
removeItem: (id: string) => void
updateQuantity: (id: string, quantity: number) => void
clearCart: () => void
total: () => number
}
export const useCartStore = create<CartStore>()(
persist(
(set, get) => ({
items: [],
addItem: (item) => set(state => {
const existing = state.items.find(i => i.id === item.id)
if (existing) {
return {
items: state.items.map(i =>
i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i
)
}
}
return { items: [...state.items, { ...item, quantity: 1 }] }
}),
removeItem: (id) => set(state => ({
items: state.items.filter(i => i.id !== id)
})),
updateQuantity: (id, quantity) => set(state => ({
items: state.items.map(i => i.id === id ? { ...i, quantity } : i)
})),
clearCart: () => set({ items: [] }),
total: () => get().items.reduce((sum, i) => sum + i.price * i.quantity, 0)
}),
{ name: 'cart-storage' }
)
)
// Usage in any component — no provider needed
function CartBadge() {
const itemCount = useCartStore(state => state.items.length)
return <span>{itemCount}</span>
}Redux Toolkit — Predictable State for Large Apps
Redux Toolkit (RTK) is the modern way to use Redux. It eliminates boilerplate while keeping Redux's powerful time-travel debugging and middleware ecosystem.
// store/posts-slice.ts
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'
export const fetchPosts = createAsyncThunk('posts/fetchAll', async () => {
const res = await fetch('/api/posts')
return res.json()
})
const postsSlice = createSlice({
name: 'posts',
initialState: { items: [], loading: false, error: null as string | null },
reducers: {
addPost: (state, action) => { state.items.push(action.payload) },
removePost: (state, action) => {
state.items = state.items.filter(p => p.id !== action.payload)
}
},
extraReducers: (builder) => {
builder
.addCase(fetchPosts.pending, (state) => { state.loading = true })
.addCase(fetchPosts.fulfilled, (state, action) => {
state.loading = false
state.items = action.payload
})
.addCase(fetchPosts.rejected, (state, action) => {
state.loading = false
state.error = action.error.message ?? 'Failed to fetch'
})
}
})
export const { addPost, removePost } = postsSlice.actions
export default postsSlice.reducerComparison at a Glance
| Criterion | Context API | Zustand | Redux Toolkit |
|---|---|---|---|
| Bundle size | 0 KB | ~2 KB | ~47 KB |
| Setup complexity | Low | Low | Medium |
| Re-render control | Coarse | Fine-grained | Fine-grained |
| DevTools | No | Yes | Yes + time travel |
| Async handling | Manual | Manual | createAsyncThunk |
| Best for | Auth, theme | Mid-size apps | Large teams, complex logic |
Common Mistakes
- Using Context for frequently-updating state (every keystroke) — causes excessive re-renders
- Adding Redux to a project with fewer than five global state slices — unnecessary overhead
- Splitting Zustand stores by component rather than by domain — creates tight coupling
- Not memoizing Context values with
useMemo, causing all consumers to re-render every time the provider re-renders
Best Practices
- Default to Context for infrequent, global values (auth, theme, locale)
- Reach for Zustand when state updates frequently or when multiple unrelated components need selective subscriptions
- Use Redux Toolkit only in large applications where time-travel debugging, strict action tracing, or complex middleware is genuinely needed
- Separate server state (API data) from client state (UI) — use React Query or SWR for server state alongside your chosen client state solution
Key Takeaways
- Context API has zero bundle cost but re-renders all consumers on every change — use it for low-frequency global state
- Zustand is 2 KB and supports granular subscriptions — only components that use changed state re-render
- Redux Toolkit is the correct choice for large teams that need deterministic state changes, time-travel debugging, and middleware
- Never use Context for high-frequency updates like search input or mouse position
- Zustand stores need no Provider component — import and use directly in any component
- Server state (API data) should be managed separately with React Query or SWR, not in Context or Redux
- You can combine solutions — Context for auth, Zustand for cart, React Query for server data
- All three solutions can coexist in one application when each solves a different problem
Advertisement