Definition
This covers the problem where, when multiple components must share frequently-changing large state as in an editor or form builder, misusing Context re-renders even unrelated parts. React Context render granularity refers to the situation where putting frequently-changing large state (an entire document tree, a whole live list, etc.) into a Context Provider's (the component that hands a value down to a subtree) value makes every subscribing descendant component re-render on each change. At that point, optimizations like React.memo (a wrapper that blocks re-renders when props don't change), props identity, and structural sharing (making only the changed part anew and reusing the rest) are all defeated. The solution is a Provider split that narrows subscription scope — splitting the frequently-changing slice and the unchanging slice into separate Providers.
Understanding in context
Even after reducing per-row re-renders to O(1), if the top-level Provider holds the whole document with useState, the Provider function re-runs on each input. The child rows are isolated with an external store, yet a "final tail" remains where only the shell container spins every time. This pattern takes the frequently-changing slice out of the Context value and, with a fixed store + useSyncExternalStore, lets each UI piece subscribe to only the snapshot it needs.
Why it's needed
Editors and form builders tend to bundle actions, meta, and the live document into one EditorProvider. When the value object becomes a new reference every time the body changes by one character, unrelated blocks like the TOC, stats, and toolbar all re-render. In large trees this leads to input latency and test failures (render count).
How it works
- A Consumer subscribes to the Provider value via
useContext. - When the Provider re-renders, as long as the value reference changes, all consumers re-render — even if an intermediate memo blocks props, it can't block the context subscription.
- An inline object
value={{ handlers, document }}is a new reference on each render. - Even if only
documentchanges, children that use onlyhandlersalso update. - A narrow Context holds only the document and derived stats, and only the TOC / word-count components sit under that Provider.
- The wide runtime context keeps only actions/meta stabilized with
useCallback/useMemo.
Practical application
- Live derivations (heading list, word count, sync status) go in a dedicated Context or an external store + selector.
- The runtime bag holds only unchanging or stabilized things: dispatch, stable callback refs, the readOnly flag, etc.
- Catch the "only N blocks update per single input" regression with block/row-level
renderCounttests. - When extending a closed union (inline node variant), guard the spots that assume
type === 'text'— typechecking reveals the ripple.
Trade-offs
- Splitting Providers increases boilerplate and tree depth.
- Context selector libraries / the React Compiler ease some cases, but a design-stage split is still clearer.
- Excessive memo makes debugging harder — document the intent with render-count tests.
When not to use it
- A small form (3 fields) — the context-split cost outweighs the gain.
- Cases where a global store (Zustand selectors, etc.) already solves granularity — Context is redundant.
- A subtree with only server components — client-context issues don't apply.
Common mistakes
- Keeping
docin the runtime forever viauseMemo(() => ({ ...handlers, doc }), [handlers, doc]). - Computing derived stats inline on each render and putting them in the value.
- Having a memoized Block subscribe to context itself rather than its child.
- Keeping the text-only assumption in markdown/range operations after extending the union.
Related concepts
- focused-guest-editor — host/guest / subscription boundaries
- usesyncexternalstore-snapshot-identity — external store snapshot
- list-virtualization-windowing — cutting render cost for large lists