Deep dive

One click, 221 re-renders: React Context can't subscribe to a field

I memoized the context value and wrapped the hot components in React.memo, standard advice, and one click still re-rendered a list 221 times. Context notifies every consumer when the value object's identity changes, regardless of which field a consumer reads, and no memo can stop that. The fix: a selector-based Zustand store.

Every React performance checklist converges on the same two moves: wrap the context value in useMemo, wrap the hot components in React.memo. I had done both, and clicking one file in Shannon’s workspace still re-rendered the FileLink activity rows 221 times. The cause is structural. React Context notifies every consumer when the provider’s value object changes identity, no matter which fields each consumer reads, and no memo can bail out a context update. So the fix had to be architectural rather than tactical: one hot slice of state moved to a Zustand store with per-field selectors, the rest stayed in Context, and I wrote down the rule for choosing so I never have to argue it from scratch again.

React.memo fixed one component and did nothing for the other

An earlier change split a monolithic WorkspaceProvider into three nested context providers: WorkspaceMetaProvider, FileSelectionProvider, and ConversationProvider. The plan’s performance section claimed that clicking a file would not re-render WorkspaceSelector or the FileLink rows, and I had verified that claim during planning by inspecting memo dependencies and each consumer’s subscription pattern. Then the branch merged, and during manual verification the message bubbles looked slow every time I clicked a file in the tree.

Turning “looks slow” into a number took a detour. The React DevTools Profiler counts renders, but it lives in a browser-extension panel that Playwright can’t drive. The workaround is to patch window.__REACT_DEVTOOLS_GLOBAL_HOOK__.onCommitFiberRoot, the injection point React exposes for any DevTools backend. In dev mode, every fiber (React’s internal unit of component work) that did work in a commit carries actualDuration > 0, so about 30 lines of patching yields per-component render counts a Playwright script can assert on.

Here is what one click on one file cost:

TriggerWorkspaceSelector rendersFileLinkImpl renders
Click file, no memo18163
Click file, React.memo on both0221

WorkspaceSelector’s renders were pure parent cascade through FileTree. It holds no subscription of its own, so React.memo cut it to zero. FileLink looked identical on paper and didn’t budge, because it subscribes to FileSelectionProvider directly, for exactly one thing: openNodeFromActivity, an action whose identity never changes. One click produced 25 React commits (a commit is React flushing one batch of work to the DOM), and across them the rows rendered 221 times while reading a value that never changed.

Context notifies on object identity, not on the fields you read

This was the provider’s value before the fix:

const value = useMemo<FileSelectionValue>(
  () => ({
    selectedFile,
    refineSelection,
    setSelectedFile,
    setRefineSelection,
    dismissRefine,
    openNodeFromActivity,
  }),
  [selectedFile, refineSelection, dismissRefine, openNodeFromActivity],
);

When selectedFile changes, the memo recomputes and returns a new object. React Context has one notification rule: new value identity means every useContext(FileSelectionContext) consumer re-renders. There is no per-field granularity to opt into. React.memo can’t rescue a consumer either, because memo bails out of parent re-renders, and a context update arrives at the consumer directly.

React 19 doesn’t change this. use(Context) has identical subscription semantics, and React Compiler memoizes within components, not across context updates. There is no memo placement that gives a Context consumer a per-field subscription; the primitive doesn’t offer one.

Open diagram in a new tab ↗

Context is a broadcast channel keyed on object identity. When consumers need to subscribe to individual fields, the primitive is wrong, and no amount of memo discipline changes that.

A selector-only store makes the wasted render unrepresentable

Per-field subscription exists in several forms, so I lined them up. Splitting the context into a state context and an actions context is the known workaround, but it fragments where state lives. Hand-rolling on useSyncExternalStore costs roughly 50 lines of subscribe/notify boilerplate. use-context-selector is a ~2KB patch on top of context. Jotai’s atoms are overkill for a tightly correlated cluster of fields, Valtio’s proxies clash with the immutable mental model the rest of the codebase uses, and TanStack Store was still in beta.

Zustand 5.x is ~3KB, mature, hooks-based, and ships with Redux DevTools support. It won on shape, not size.

The store:

// web/src/stores/fileSelection.ts
export function createFileSelectionStore(
  revealNodeInTreeRef: MutableRefObject<(nodeId: string) => void>,
): FileSelectionStore {
  return create<FileSelectionState>((set) => ({
    selectedFile: null,
    refineSelection: null,
    setSelectedFile: (file) => set({ selectedFile: file }),
    setRefineSelection: (selection) => set({ refineSelection: selection }),
    dismissRefine: () => set({ refineSelection: null }),
    openNodeFromActivity: (node) => {
      if (node.type === "file") {
        set({ selectedFile: { id: node.id, name: node.name } });
      }
      revealNodeInTreeRef.current(node.id);
    },
  }));
}

And the consumer that had been re-rendering 221 times:

// FileLink
const openNodeFromActivity = useFileSelection((s) => s.openNodeFromActivity);
// Re-renders only when openNodeFromActivity's identity changes — which never happens.

The public API is selector-only: useFileSelection(selector) and nothing else. A consumer that needs two fields calls the hook twice. That discipline came out of a plan review that flagged three real issues before I wrote any code. My first draft used useShallow((s) => ({ ... })) for multi-field consumers, guarded only by a checklist warning against the s => ({a, b}) foot-gun; the reviewer pushed for one hook call per field, which makes the foot-gun unrepresentable instead of merely discouraged. It also caught a closure-capture of revealNodeInTree that should have stayed on the existing ref pattern, and a public useFileSelectionStore() escape hatch for tests that it rightly called a back door past the selector model. That hook moved to a separate /internal path.

The migration landed in three commits: dependencies, store, then provider and consumers.

Two unit tests lock the property in:

it("does not re-render consumer of openNodeFromActivity when selectedFile changes", () => {
  let renders = 0;
  function Consumer() {
    renders++;
    useFileSelection((s) => s.openNodeFromActivity);
    return null;
  }
  // ... render
  expect(renders).toBe(1);
  act(() => store.setState({ selectedFile: { id: "f-1", name: "test.md" } }));
  expect(renders).toBe(1); // catches any regression that breaks selective subscription
});

That test is the perf claim, enforced rather than asserted. Anyone who reintroduces a coupling between these consumers and selectedFile breaks CI, not the message bubbles.

Only one of four providers earned the migration

The workspace has four sibling providers: WorkspaceMetaProvider, FileSelectionProvider, ConversationProvider, and AgentRunProvider. Under measurement, exactly one showed the cascade, so exactly one migrated. Moving the others on suspicion would have inflated scope and blast radius with no evidence the migration was needed. Selective subscription is a fix for a measured problem, not a default architecture.

The post-fix measurement keeps me honest here. FileLink renders on a file click dropped from 221 to 90, not to zero. The unit tests prove the Zustand path itself contributes nothing; the residual 90 is a parent cascade through AgentPanel, which consumes useAgentRunContext(). That makes AgentRunProvider the structurally identical next candidate, and it has a planned follow-up, scheduled after the related work in this area lands. Even there, the plan’s first step is the same measurement, because “structurally identical” is exactly the kind of claim this incident taught me not to trust on inspection.

The rule I wrote down: state owned by the server belongs to TanStack Query. Client state whose value object holds multiple fields with different change frequencies and disjoint consumers gets a per-slice Zustand store behind a selector-only API. Context keeps the quiet state: tree-scoped, low-frequency, or single-field.

Open diagram in a new tab ↗

Static review turned “should not re-render” into “does not re-render”

Zustand had been rejected long before any of this. The original plan ruled it out under “no new dependencies,” and all three plans that followed carried that rejection forward. The framing was “is 3KB worth it,” never “3KB versus 50 lines of hand-rolled useSyncExternalStore versus a fragmented state/actions split.” A cost-only rejection with no named alternative is a decision to do nothing, made quietly, and it held for three plans in a row.

The perf claim itself survived two assessment rounds and extensive review because every check was static. Reading memo dependencies and subscription patterns produced an internally consistent story, and “should not re-render” hardened into “does not re-render” without anyone running anything. The cascade also had no DOM signal to catch: click a file and the editor opens, the tree highlights, the panel updates. Everything correct, except 221 renders nobody could see.

So the invariant I now hold plans to: a claim about render counts ships with a test that counts renders. “Should re-render” and “does re-render” are different claims, and only one of them is testable.

Found a mistake, or want to argue about an invariant? eng@shannon.dev or send a PR on the blog repo.