Q: When should you use React Context vs a state management library like Redux/Zustand?

Answer:

React.Context and external state managers (Redux Toolkit, Zustand, Jotai, Recoil) solve overlapping but distinct problems. Context is a dependency injection mechanism, not a state engine; treating it as one leads to performance issues.

What Context Actually Does

Context propagates a value down the tree without prop drilling. When the Provider's value reference changes, every consumer re-renders — there is no built-in selector or partial subscription.

const ThemeCtx = createContext('light');
function App() { return <ThemeCtx.Provider value="dark"><Page/></ThemeCtx.Provider>; }
function Btn() { const theme = useContext(ThemeCtx); /* re-renders on any value change */ }

What State Libraries Add

FeatureContextRedux ToolkitZustandJotai
Selector-based subscriptionsNoYes (useSelector)Yes (useStore(s => s.x))Atomic
Reference equality opt-inNoYesYes (shallow)N/A
Time-travel / DevToolsNoYesYes (via DevTools middleware)Limited
Middleware (logging, async)NoYes (thunks, RTK Query)YesNo (uses atoms + effects)
BoilerplateNoneHigherLowLow
Bundle size0~10 KB~1 KB~3 KB

Rule of Thumb

   Is the value rarely-changing config (theme, locale, current user, feature flags)?
        |-- Yes --> Context
        |
   Is the value updated frequently or read by many components?
        |-- Yes --> external store (Redux/Zustand/Jotai)
        |
   Is it server data (lists, entities, requests)?
        |-- Yes --> data-fetching cache (TanStack Query, RTK Query, SWR)

[!NOTE] The single most common React perf bug in 2025 codebases: pushing rapidly-changing values (form input strings, mouse positions) through Context. Every keystroke re-renders the entire subtree under the Provider.

Splitting Context to Mitigate

If you must use Context for a changing value, split by change rate:

const StateCtx    = createContext(null);   // changes often
const DispatchCtx = createContext(null);   // stable reference

<StateCtx.Provider value={state}>
  <DispatchCtx.Provider value={dispatch}>  {/* never re-renders */}
    {children}
  </DispatchCtx.Provider>
</StateCtx.Provider>

Components that only call dispatch subscribe only to DispatchCtx and avoid the noise.

Redux Toolkit — When It Earns Its Keep

  • Complex domain logic with many reducers, selectors, and asynchronous workflows.
  • Need for serializable state, replayable actions, audit logs.
  • Team is large and benefits from explicit action contracts.
  • Server cache management with RTK Query.
const slice = createSlice({
  name: 'cart',
  initialState: { items: [] },
  reducers: {
    add(state, { payload }) { state.items.push(payload); },
    remove(state, { payload }) {
      state.items = state.items.filter(i => i.id !== payload);
    },
  },
});

Immer powers the "mutate state" syntax safely.

Zustand — Pragmatic Middle Ground

import { create } from 'zustand';
const useCart = create((set) => ({
  items: [],
  add: (item) => set(s => ({ items: [...s.items, item] })),
}));

function Count() {
  return <span>{useCart(s => s.items.length)}</span>; // re-renders only when count changes
}

No Provider needed; selectors give fine-grained subscriptions; full TS inference.

Jotai / Recoil — Atomic State

State is decomposed into small atoms. Components subscribe to only the atoms they read, getting maximally fine-grained re-renders.

const countAtom = atom(0);
function Counter() { const [n, set] = useAtom(countAtom); }

Useful for highly interactive UIs (graph editors, design tools) where the bottleneck is propagating updates.

Server State Is Different

Caching, refetching, mutations, optimistic updates, and revalidation belong in TanStack Query / SWR, not in Redux. Mixing the two leads to duplicated, drifting data. Treat the server as the source of truth and the client store for truly client-only state (UI flags, drafts, selection).