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:
| Trigger | WorkspaceSelector renders | FileLinkImpl renders |
|---|---|---|
| Click file, no memo | 18 | 163 |
Click file, React.memo on both | 0 | 221 |
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.
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.
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.