React.js, part 9: Context without the re-render storm — splitting providers and stabilizing values
Part 9from the React.js series · 20 parts in all
Context is the first state-sharing tool everyone reaches for and one of the most common performance regrets. The failure mode is predictable: one big provider object changes, and every consumer in the app re-renders even though it only read an unrelated field. Part 9 is the discipline that avoids it.
Why it happens
React compares the context value by reference. If you build the value inline —
value={{ user, theme, dispatch }} — it is a new object on every render of the
provider, so every consumer is notified every time:
// Every render of App creates a new object - all consumers re-render.
<AuthContext.Provider value={{ user, login, logout }}>
{children}
</AuthContext.Provider>
Fix one: stabilize the value
const value = useMemo(() => ({ user, login, logout }), [user, login, logout]);
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
The memo only helps if its dependencies are stable too — login and
logout should be useCallback-wrapped, or the memo recomputes every
render anyway and you are back where you started.
Fix two: split the contexts
Stabilizing one value is not enough when the value mixes fast-changing and slow-changing data. The better shape is two providers: a state context and a dispatch/actions context. Consumers that only dispatch never re-render when the state changes, because the actions object is stable forever:
const StateContext = createContext(null);
const ActionsContext = createContext(null);
export function Store({ children }) {
const [cart, dispatch] = useReducer(reducer, initial);
const actions = useMemo(() => ({
add: (item) => dispatch({ type: 'add', item }),
remove: (id) => dispatch({ type: 'remove', id }),
}), []); // stable: memo never recomputes
return (
<StateContext.Provider value={cart}>
<ActionsContext.Provider value={actions}>{children}</ActionsContext.Provider>
</StateContext.Provider>
);
}
Fix three: select, or skip Context
Context has no selector: a consumer takes the whole value. When you genuinely need
"subscribe to one slice", that is a job for useSyncExternalStore (part 6) or a
store library that offers selectors. Context is best reserved for values that change
rarely — theme, locale, the current user, dependency injection — and not for hot,
per-keystroke data. Next: the React Compiler, which tries to make half of this automatic.