ML
React

Context and Re-renders: Value Identity, Splitting, and When to Reach for a Store

React Context broadcasts to every consumer whenever the provider value's identity changes, so a fresh object each render quietly triggers a re-render storm.

May 28, 202610 min readReactPerformance

React Context is a dependency-injection mechanism, not a state manager. Its one performance rule decides everything: every consumer of a context re-renders when the provider's value changes identity, regardless of which slice each consumer actually reads.

1. The mechanism: identity, not deep equality

When a Provider renders with a new value, React compares it to the previous value with Object.is. If they differ, React schedules a re-render for every component that subscribes to that context (via useContext, or the use(Context) hook in React 19). There is no shallow comparison, no selector, no field-level subscription. A consumer cannot opt out by "only reading user.name" - it subscribes to the whole value, and any identity change re-renders it.

The interview-crisp version: context propagation is keyed on referential identity of the provider value, and it bypasses React.memo. A memoized component still re-renders if it consumes a context whose value changed - context updates are delivered directly to consumers, not through the normal props path that memo guards.

2. The new-object-every-render footgun

The most common mistake is constructing the value inline:

function AppProvider({ children }) {
  const [user, setUser] = useState(null);
  const [theme, setTheme] = useState("light");

  // New object literal on EVERY render of AppProvider.
  return (
    <AppContext.Provider value={{ user, setUser, theme, setTheme }}>
      {children}
    </AppContext.Provider>
  );
}

The object { user, setUser, theme, setTheme } is allocated fresh on each render. Even when nothing meaningful changed - say a parent re-rendered for an unrelated reason - the value's identity differs, so Object.is(prev, next) is false, and every consumer in the tree re-renders. Worse, a consumer that only reads theme re-renders when user changes, because they share one value object.

This is the re-render storm: one provider feeding many consumers, all waking up on any state change anywhere in the provider.

3. First fix: memoize the value

Stabilize the value's identity with useMemo so it only changes when its inputs do:

function AppProvider({ children }) {
  const [user, setUser] = useState(null);
  const [theme, setTheme] = useState("light");

  const value = useMemo(
    () => ({ user, setUser, theme, setTheme }),
    [user, theme] // setUser/setTheme are stable; omit them
  );

  return (
    <AppContext.Provider value={value}>{children}</AppContext.Provider>
  );
}

Two precise points. First, the state setters returned by useState (and dispatch from useReducer) have stable identity for the lifetime of the component, so they do not belong in the dependency array. Second, memoizing only solves the spurious-render problem - renders caused by an unrelated parent. It does not solve the coupling problem: when user genuinely changes, the value identity must change, and consumers reading only theme still re-render. Memoization narrows the blast radius to real changes; it does not target who cares about them.

4. Real fix: split the context

The structural cure is to give each independently-changing concern its own context. Two patterns dominate.

Split state from dispatch. With useReducer, the dispatch function is stable for the component's lifetime, so components that only dispatch should never re-render from a state change:

const StateContext = createContext(null);
const DispatchContext = createContext(null);

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

// A button that only fires actions does not re-render on state change:
function AddButton() {
  const dispatch = useContext(DispatchContext);
  return <button onClick={() => dispatch({ type: "add" })}>Add</button>;
}

Because dispatch is stable, DispatchContext's value never changes identity, so a state update never re-renders AddButton through that context. (It would still re-render if its own parent re-rendered for another reason - wrap it in React.memo if that matters.) This is the canonical, zero-dependency way to let writers ignore reads.

Split by domain. If user and theme change on different schedules, they should be different contexts. Then a theme consumer re-renders only on theme changes:

const UserContext = createContext(null);
const ThemeContext = createContext("light");

function AppProvider({ children }) {
  const [user, setUser] = useState(null);
  const [theme, setTheme] = useState("light");

  const userValue = useMemo(() => ({ user, setUser }), [user]);
  const themeValue = useMemo(() => ({ theme, setTheme }), [theme]);

  return (
    <UserContext.Provider value={userValue}>
      <ThemeContext.Provider value={themeValue}>
        {children}
      </ThemeContext.Provider>
    </UserContext.Provider>
  );
}

The combinatorial nesting is the cost; the benefit is that each consumer subscribes to the smallest unit of change that concerns it. Splitting is usually more effective than memoization, because it changes who re-renders, not just how often.

5. A complementary lever: stabilize the children

Context updates re-render consumers; they do not, by themselves, re-render every node in the subtree. A component that sits inside a provider but does not consume the context only re-renders if its parent re-renders. So if a non-consuming subtree is passed as the children prop of the provider, and the provider component itself does not re-render, those children keep the same element reference and React bails out of re-rendering them:

// <ExpensiveTree/> is created in the parent and passed as children;
// it does not consume the context, so a provider state change alone
// does not force it to re-render.
<AppProvider>
  <ExpensiveTree />
</AppProvider>

This is why the children-as-prop pattern matters: it keeps the heavy, context-agnostic part of the tree out of the provider's own render path, so the provider's state changes do not recreate those elements.

6. When context is the wrong tool

Context has no built-in selector model - every consumer takes the whole value and re-renders on any identity change. That makes it a poor fit for high-frequency, fine-grained state: cursor or pointer position, a fast-updating form field shared widely, animation or scroll values, real-time prices. Splitting and memoizing only push the problem around when updates fire many times per second across many subscribers.

The right tool there is an external store with subscription-level granularity. React 18 added useSyncExternalStore for exactly this: a store holds the mutable value, components subscribe, and each component reads a selected slice, re-rendering only when that slice changes:

// store.js - a minimal external store
function createStore(initial) {
  let state = initial;
  const listeners = new Set();
  return {
    getSnapshot: () => state,
    setState(next) {
      state = { ...state, ...next };
      listeners.forEach((l) => l());
    },
    subscribe(l) {
      listeners.add(l);
      return () => listeners.delete(l);
    },
  };
}

// component - subscribe to a selected slice only
function usePointerX(store) {
  return useSyncExternalStore(
    store.subscribe,
    () => store.getSnapshot().pointer.x // a primitive: stable across calls
  );
}

The store lives outside React, so writes do not flow through a provider re-render. Each subscriber re-renders only when its selected value changes. One rule matters: getSnapshot must return a referentially stable result for unchanged state - return a primitive (as above) or a cached/memoized object, never a fresh object literal each call, or React will loop. For server rendering, pass a third getServerSnapshot argument; without it the hook throws during SSR/hydration. Libraries like Zustand, Jotai, Redux, and Valtio are productionized versions of this idea, with selectors, equality functions, and devtools. Reach for them when you need selective subscription rather than fighting context's all-or-nothing propagation.

useSyncExternalStore is also the correct way to read an externally mutated source under React's concurrent rendering, because it protects against tearing - the situation where a value changes mid-render and different parts of one commit observe different values. Plain context cannot guarantee that for a source mutated outside React. Note the boundary rule, though: useSyncExternalStore, useContext, and all state hooks run only in Client Components. React Server Components have no state and cannot read context or subscribe to a store; data flows into them as props or via async fetches. A common pattern is to put the store instance in a context for injection on the client, then read from it with selectors - context for the low-frequency handle, the store for high-churn values.

The division of labor that mature apps settle on: context for low-frequency, tree-scoped dependency injection (theme, current user, locale, a store handle); an external store for the actual high-churn application state.

Rules of thumb

  • Context re-renders consumers on Object.is identity change of the provider value, ignores deep equality, and bypasses React.memo - never pass a fresh object literal as value.
  • Memoize the value with useMemo to kill spurious renders; useState setters and useReducer dispatch are already stable, so leave them out of deps.
  • Split state from dispatch, and split by domain - it changes who re-renders, which memoization alone never does.
  • Pass context-agnostic subtrees as children so a provider's own state changes do not drag them along.
  • For high-frequency or selector-driven state, leave context and use useSyncExternalStore or a store library; its getSnapshot must return a stable result for unchanged state to avoid render loops.
  • Only Client Components use context, state hooks, or useSyncExternalStore; Server Components have no state and receive data as props.
SharePostLinkedIn

Reader Discussion

1 replies// weighed in

TopNewestAuthor
Add to the thread
Disagree, agree harder, or share your own experience…
Email instead →markdown okbe kind
  1. Léa Dubois· SREAsks

    any chance you'd publish these as a PDF collection? would love to print and read offline on flights. screen-fatigue is real.

    Jun 03, 2026·6 days later

Worked on something similar? Email ducminhldm@gmail.com — I read every one. The good ones become future posts.

Comments seeded · live discussion via email