React Context vs Zustand vs Redux — Choosing the Right State Manager

Sanjeev SharmaSanjeev Sharma
6 min read

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.reducer

Comparison at a Glance

CriterionContext APIZustandRedux Toolkit
Bundle size0 KB~2 KB~47 KB
Setup complexityLowLowMedium
Re-render controlCoarseFine-grainedFine-grained
DevToolsNoYesYes + time travel
Async handlingManualManualcreateAsyncThunk
Best forAuth, themeMid-size appsLarge 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

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading