Back to React
React
medium
mid

How can the React Context API be used for state management?

Context API can act as lightweight global state for rarely-updated values (auth, theme, locale). Combine with useReducer for action-based updates. Split contexts by update frequency to limit re-renders. Memoize the Provider value. NOT a substitute for Redux/Zustand when state updates often or many components subscribe — Context re-renders every consumer on every value change.

7 min read·~5 min to think through

Context can do state management but has performance limits. Understand them before reaching for it.

Basic Context state

tsx
const ThemeContext = createContext<{ theme: 'light' | 'dark'; toggle: () => void } | null>(null);

export function ThemeProvider({ children }: { children: ReactNode }) {
  const [theme, setTheme] = useState<'light' | 'dark'>('light');
  const toggle = useCallback(() => setTheme(t => t === 'light' ? 'dark' : 'light'), []);
  const value = useMemo(() => ({ theme, toggle }), [theme, toggle]);
  return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}

export function useTheme() {
  const ctx = useContext(ThemeContext);
  if (!ctx) throw new Error('useTheme outside ThemeProvider');
  return ctx;
}

Context + useReducer pattern

For action-based updates:

tsx
type State = { user: User | null; cart: CartItem[] };
type Action =
  | { type: 'login'; user: User }
  | { type: 'logout' }
  | { type: 'addToCart'; item: CartItem };

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case 'login':     return { ...state, user: action.user };
    case 'logout':    return { ...state, user: null };
    case 'addToCart': return { ...state, cart: [...state.cart, action.item] };
  }
}

const StateCtx = createContext<State | null>(null);
const DispatchCtx = createContext<React.Dispatch<Action> | null>(null);

function AppProvider({ children }: { children: ReactNode }) {
  const [state, dispatch] = useReducer(reducer, { user: null, cart: [] });
  return (
    <StateCtx.Provider value={state}>
      <DispatchCtx.Provider value={dispatch}>{children}</DispatchCtx.Provider>
    </StateCtx.Provider>
  );
}

Split state and dispatch into separate contexts so dispatch-only consumers don't re-render on state changes.

The re-render problem

Every consumer of a context re-renders when the value changes. With one big context:

tsx
<AppContext.Provider value={{ user, cart, theme, notifications }}>

A notification arriving re-renders every component that uses any field. Even components that only read theme get re-rendered.

Fixes

1. Split contexts.

tsx
<UserContext.Provider value={user}>
  <CartContext.Provider value={cart}>
    <NotificationsContext.Provider value={notifications}>
      <ThemeContext.Provider value={theme}>
        {children}
      </ThemeContext.Provider>
    </NotificationsContext.Provider>
  </CartContext.Provider>
</UserContext.Provider>

2. use-context-selector library.

tsx
const userName = useContextSelector(AppContext, c => c.user?.name);

Re-renders only when the selected slice changes.

3. Switch to a real state library when context isn't enough.

When Context is enough

  • Auth / current user: read by many, changes rarely (login/logout).
  • Theme / locale / feature flags: read everywhere, almost never change.
  • Dispatch only: stable identity, no re-render concerns.
  • Compound component config: shared between Tab + TabList children.

When Context is NOT enough

  • State updates frequently (mouse position, scroll, form per-keystroke).
  • Many components subscribe and re-render is expensive.
  • You need selectors to limit subscription scope.
  • You need middleware (logging, devtools, persistence).

In those cases: Zustand, Jotai, Redux Toolkit.

Common mistakes

  • No useMemo on value — new object every render, every consumer re-renders.
  • One mega-context — every change cascades.
  • Reading multiple unrelated values from one context — bind your re-renders to the most volatile field.
  • Treating Context as Redux replacement without acknowledging the re-render cost.

Comparison

ToolBest for
ContextRarely changing, broadly read state (auth, theme)
Context + useReducerAction-based local state for a feature
ZustandFrequent updates + selector subscriptions
JotaiAtomic state with fine-grained reactivity
Redux ToolkitLarge apps with devtools, middleware, async patterns
React QueryServer state

Senior framing

Context is for dependency injection of values that change rarely. It's not 'free Redux'. When you find yourself optimizing context re-renders with split providers and selectors, you've outgrown it — reach for a real store.

Follow-up questions

  • Why does memoizing the Provider value matter?
  • How would you handle frequent updates with Context?
  • What's the use-context-selector library and why does it exist?

Common mistakes

  • Fresh object/function as Provider value each render.
  • One mega-context — every update cascades.
  • Treating Context as a free Redux replacement.

Performance considerations

  • Every consumer re-renders on value change. Memoize value with useMemo. Split contexts. For high-frequency updates, use selectors (use-context-selector) or switch to a state library.

Edge cases

  • useContext bypasses React.memo — consumers re-render on value change regardless.
  • Default context value used only when no Provider is found above.
  • Multiple Providers for the same context: nearest wins.

Real-world examples

  • Every React app uses Context for auth, theme, router. State libraries like Redux use a single root Context to expose the store, but the store itself manages selective subscriptions internally.

Senior engineer discussion

Senior framing: Context is for DI. It has natural limits as global state. Knowing when you've crossed those limits (re-render storms, no selectors, complex async) and reaching for the right tool is the senior signal.

Related questions