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.
Q: How do you measure Context-related re-renders?
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
memoonly if the prop change isn’t from context — amemo’d component still re-renders when itsuseContextvalue changes. useContextre-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
createContextis 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; } useContextwith referentially-equal new value ({}vs{}— different refs) re-renders unnecessarily;Object.isdoesn’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
- React Profiler (where you measure context re-renders): ../15_performance/09_react_profiling.md
- State managers comparison: ../07_state_managers/
- Memoization heuristics (when memo helps vs hurts): ../15_performance/10_memoization_heuristics.md
- Server Components (RSC context boundary): server_components.md
Further reading
- React docs —
useContext: https://react.dev/reference/react/useContext use-context-selector: https://github.com/dai-shi/use-context-selector- Zustand: https://zustand-demo.pmnd.rs/
- Mark Erikson — “When (and why) should you reach for Redux?”: https://blog.isquaredsoftware.com/2021/01/context-redux-differences/