Context Performance Pitfalls
React Context is a convenient way to share state without prop drilling, but it has a sharp performance edge that catches many teams off guard. The rule is simple and unforgiving: every component that consumes a context re-renders whenever the context value changes, regardless of which part of that value it actually uses. Understanding this behavior — and the patterns that tame it — is the difference between Context as a clean architectural tool and Context as a source of mysterious slowdowns.
How context propagates updates
When a Provider’s value changes by reference (a === comparison fails), React schedules a re-render for all consumers below it. There is no built-in partial subscription: a component reading only user.name re-renders just as eagerly as one reading the entire object. Even React.memo will not save a consumer, because consuming context bypasses the props-equality short-circuit entirely.
Consider a context that holds both rarely-changing user data and a frequently-ticking counter:
import { createContext, useContext, useState } from 'react';
const AppContext = createContext(null);
function AppProvider({ children }) {
const [user, setUser] = useState({ name: 'Ada' });
const [count, setCount] = useState(0);
// New object on EVERY render — guarantees consumer re-renders.
return (
<AppContext.Provider value={{ user, setUser, count, setCount }}>
{children}
</AppContext.Provider>
);
}
Every time count updates, the provider re-renders, builds a fresh value object, and forces every consumer to re-render — including ones that only care about user.
The new-object-each-render trap
The most common mistake is constructing the value inline. Even if none of the underlying state changed, { user, setUser } is a brand-new object each render, so the reference check always fails and consumers re-render needlessly. The fix is to memoize the value with useMemo.
import { createContext, useMemo, useState } from 'react';
const UserContext = createContext(null);
function UserProvider({ children }) {
const [user, setUser] = useState({ name: 'Ada' });
// Stable reference: only changes when `user` changes.
const value = useMemo(() => ({ user, setUser }), [user]);
return <UserContext.Provider value={value}>{children}</UserContext.Provider>;
}
Tip:
setUserfromuseStateis guaranteed stable across renders, so it does not need to appear in the dependency array. Only include values that can actually change.
Splitting state and dispatch contexts
Memoizing helps, but it does not solve the bundled-data problem: when user does change, consumers reading only setUser still re-render. A powerful pattern is to split a single context into two — one for the state that changes, and one for the stable dispatch/setters that never do. Components that only dispatch actions never re-render when state changes.
import { createContext, useContext, useReducer } from 'react';
const StateContext = createContext(null);
const DispatchContext = createContext(null);
function reducer(state, action) {
switch (action.type) {
case 'increment':
return { ...state, count: state.count + 1 };
default:
return state;
}
}
export function CounterProvider({ children }) {
const [state, dispatch] = useReducer(reducer, { count: 0 });
// `dispatch` is referentially stable, so its context value never changes.
return (
<DispatchContext.Provider value={dispatch}>
<StateContext.Provider value={state}>{children}</StateContext.Provider>
</DispatchContext.Provider>
);
}
export const useCounterState = () => useContext(StateContext);
export const useCounterDispatch = () => useContext(DispatchContext);
Now a button that only calls dispatch subscribes to DispatchContext and is completely insulated from state churn:
function IncrementButton() {
const dispatch = useCounterDispatch();
console.log('IncrementButton render');
return <button onClick={() => dispatch({ type: 'increment' })}>+1</button>;
}
Output:
IncrementButton render // logged once, on mount only
No matter how many times the count changes, this button never re-renders.
Selector-based subscriptions
Splitting contexts works well for a handful of slices, but it scales poorly when a single store has many fields. Libraries like use-context-selector bring fine-grained, selector-based subscriptions to Context: a consumer re-renders only when the selected slice changes.
import { createContext, useContextSelector } from 'use-context-selector';
const StoreContext = createContext(null);
function UserName() {
// Re-renders ONLY when `state.user.name` changes.
const name = useContextSelector(StoreContext, (state) => state.user.name);
return <span>{name}</span>;
}
This gives you Redux-style selector ergonomics while staying within the Context API. The table below compares the strategies.
| Strategy | Granularity | Extra deps | Best for |
|---|---|---|---|
Memoize value | Provider-wide | None | Any context — the baseline fix |
| Split state/dispatch | Two slices | None | Action-heavy UIs, reducers |
use-context-selector | Per-field | One library | Large stores, many fields |
| Dedicated store (Zustand/Jotai) | Per-atom/selector | One library | Complex global state |
Best Practices
- Always wrap a non-primitive context
valueinuseMemoto give it a stable reference. - Keep stable setters (
dispatch,useStatesetters) out ofuseMemodependency arrays. - Split changing state from stable dispatch into separate providers so action-only components never re-render.
- Reach for
use-context-selectorwhen a single store has many independent fields read by many components. - Place providers as low in the tree as possible so fewer components sit inside the re-render blast radius.
- If you find yourself fighting Context performance constantly, consider a dedicated store library built for selector subscriptions.