frontend / react / context_performance.md

Context Performance Traps

6 min read source

Context Performance Traps

TL;DR

React.Context is simple to use, dangerous at scale. Every consumer re-renders when the Provider’s value reference changes — and a fresh object/array as value is the #1 perf bug. Senior fixes: memoize the value, split context by domain so consumers don’t subscribe to unrelated changes, use a state library (Zustand, Jotai, Redux + useSyncExternalStore) when you need selector-based subscription, and understand when Context is the wrong tool entirely.

Interview Q&A

Q: When does a Context consumer re-render?

A: Whenever the Provider’s value prop changes by reference (Object.is). Not by deep equality — reference. So a new object literal each render is “always changed.”

// Bad — new object every render → all consumers re-render
<ThemeContext.Provider value={{ theme: "dark", toggle }}>

// Good — stable reference
const value = useMemo(() => ({ theme, toggle }), [theme]);
<ThemeContext.Provider value={value}>

Even if theme hasn’t changed, the inline object is a new reference → every consumer re-renders.

Q: How is this different from a state library’s useSelector?

A: Context fires for all consumers on value change. A library like Redux/Zustand subscribes via a selector and only fires when the selected slice changes — far more targeted.

// Context — all consumers re-render on any value change
const { user, theme, locale } = useContext(AppContext);

// Zustand — only re-renders if user changes
const user = useStore((s) => s.user);

If your context holds many semi-independent values and consumers each care about one, Context is the wrong tool — every consumer of any field re-renders on any change.

Q: Split context by domain — what does that look like?

A: Multiple Providers, each with a focused value shape:

// Before — one context, every change re-renders everyone
<AppContext.Provider value={{ user, theme, locale, settings }}>

// After — split by what changes together
<UserContext.Provider value={user}>
  <ThemeContext.Provider value={theme}>
    <LocaleContext.Provider value={locale}>
      ...
    </LocaleContext.Provider>
  </ThemeContext.Provider>
</UserContext.Provider>

A component using only useContext(ThemeContext) no longer re-renders when user changes. The boilerplate cost is the wrapping; the perf win is real.

Q: Split read context vs write context.

A: A common pattern when consumers either read state or dispatch actions (not both):

const StateContext = createContext<State>(null!);
const DispatchContext = createContext<Dispatch>(null!);

function Provider({ children }) {
  const [state, dispatch] = useReducer(reducer, initialState);
  return (
    <DispatchContext.Provider value={dispatch}>      {/* dispatch is stable */}
      <StateContext.Provider value={state}>
        {children}
      </StateContext.Provider>
    </DispatchContext.Provider>
  );
}

// Read-only consumers — re-render on state change
function MyDisplay() {
  const state = useContext(StateContext);
  return <div>{state.x}</div>;
}

// Write-only consumers — never re-render (dispatch is stable)
function MyButton() {
  const dispatch = useContext(DispatchContext);
  return <button onClick={() => dispatch({ type: "x" })}>Go</button>;
}

Components that only dispatch never re-render because the dispatch function is stable across useReducer renders. Halves the re-render churn in many apps.

Q: When does useMemo on the value not help?

A: When the inputs to the useMemo themselves change every render. Example:

function Provider({ user, settings }) {
  const newSettings = { ...settings, computed: getComputed() };   // new object every render
  const value = useMemo(() => ({ user, settings: newSettings }), [user, newSettings]);
  return <Ctx.Provider value={value}>...</Ctx.Provider>;
}

newSettings is a new reference every render → useMemo cache misses every render → value changes every render. Fix the upstream object first.

Q: useContextSelector — what is it?

A: A proposal (and use-context-selector library) for “subscribe to a slice of context.” Mirrors Redux’s useSelector:

import { createContext, useContextSelector } from "use-context-selector";

const AppContext = createContext({ user: null, theme: "light" });

function ThemedHeader() {
  // Only re-renders when theme changes, not when user changes
  const theme = useContextSelector(AppContext, (state) => state.theme);
  return <header data-theme={theme}>...</header>;
}

Useful when you can’t easily split context and the consumer pattern is selector-based. But it’s a third-party library; native React doesn’t have selector subscription on Context.

The standard library answer for “I want selector-based subscription” is useSyncExternalStore with a state library — Zustand, Jotai, Redux. Don’t fight Context’s design when a store is what you actually want.

Q: Context vs Zustand/Jotai/Redux — when each?

A:

Context Zustand / Jotai / Redux (+ useSyncExternalStore)
Subscription model all consumers re-render on value change selector-based; only re-render if selected slice changed
Best for dependency-style values (theme, auth user, API client, locale) cross-component state with many independent subscribers
Boilerplate minimal — just Provider + useContext requires a store + selectors
DevTools none (Context is just plumbing) yes — Redux DevTools, Zustand devtools

The senior rule: Context for cross-cutting dependencies with few changes; state library for state with many subscribers.

Q: Common Context anti-patterns.

A:

  • Inline object as value — fresh reference, all consumers re-render. The canonical bug.
  • Putting frequently-changing state in Context — a counter that updates 60 times a second in one Context will re-render every consumer 60 times.
  • Mega-context that holds everything (user, theme, settings, cart, notifications) — any update re-renders the whole tree of consumers.
  • Using Context to replace prop drilling for 2 levels — props are cleaner; Context for 3+ levels or many siblings.
  • Provider re-creating reducer state every render — useReducer’s state is stable; useState is too; but a fresh function in a Provider body recreates them.

A: React DevTools Profiler with “why did this render?” enabled. Look for components that re-render with the reason “Context changed” — those are subscribers triggered by your Provider. If many components re-render on a single unrelated change, your Context is too coarse.

Q: Are server-component contexts different?

A: Yes — context values you read in Server Components are not the same as client context. The boundary is enforced. To pass values across, use a Server Component that renders a Client <Provider> with the needed value as props.

// Server component
export default async function Layout() {
  const user = await getCurrentUser();
  return <ClientProvider user={user}><App /></ClientProvider>;
}

// Client provider (own file with "use client")
"use client";
export function ClientProvider({ user, children }) {
  return <UserContext.Provider value={user}>{children}</UserContext.Provider>;
}

Gotchas / edge cases

  • Context re-renders are bypassed by memo only if the prop change isn’t from context — a memo’d component still re-renders when its useContext value changes.
  • useContext re-runs whenever the value changes, even if the component code path is gated. To skip re-renders for unused branches, restructure into smaller components.
  • Provider unmounted = value gone — consumers below get the default value. Common bug when nav unmounts the provider tree.
  • Default value of createContext is used only when there’s no Provider above. Don’t rely on it for real defaults; throw an error to surface missing Providers:
    const Ctx = createContext<T | null>(null);
    function useCtx() {
      const v = useContext(Ctx);
      if (!v) throw new Error("missing Provider");
      return v;
    }
  • useContext with referentially-equal new value ({} vs {} — different refs) re-renders unnecessarily; Object.is doesn’t help.
  • Concurrent rendering + context — context values are captured per-render; transitions can show stale context briefly. Generally fine.

What a senior is expected to say

  • “Context fires for all consumers on value-reference change. Memoize the value, and split context by domain so unrelated changes don’t ripple.”
  • “Splitting state and dispatch into two contexts is a cheap win — components that only dispatch never re-render.”
  • “For selector-based subscription, use a state library with useSyncExternalStore (Zustand, Jotai). Don’t fight Context’s design.”
  • “Context for dependencies (theme, auth, API client, locale). State libraries for state with many subscribers.”
  • “Mega-context with theme + user + cart + notifications is an anti-pattern — split or move to a store.”
  • “Server Components can’t share Context with Client Components directly — hop through a Client Provider that takes props.”

Cross-references

Further reading