Trust is earned, not given

A different perspective

2024-05-07 · Projects

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.