Definition
This is the pattern to use when, in a deep tree UI like an outliner or nested comments, editing just one bottom row re-renders the whole screen, and you want to narrow that re-render scope to a single row. Normalized render entry projection keeps an immutable tree (or graph) as the write model (the original source that mutations change, the real data), and separately maintains a normalized map {id → entry} (a flat map that flattens the nesting so you can look items up directly by id) as a derived read-model (a read-only copy) dedicated to screen rendering. Then each row subscribes to only its own id via useSyncExternalStore (the hook that subscribes a component to state outside React), so that editing one node reduces the re-render target from proportional to path length (O(depth)) to a single row (O(1)). It's a form derived from the CQRS (Command Query Responsibility Segregation) idea of separating the side that changes data (write) from the side that reads and displays it (read), with the read model sliced into fine UI-subscription units.
Understanding in context
In a tree UI like nested blocks or comment threads, even editing just one bottom row makes the parent and grandparent new objects because of immutable updates. If you pass the whole tree down as props, distant sibling and ancestor rows re-render too — like "flipping one light switch flickers other rooms on the same wire." This pattern leaves the original tree alone, updates only the on-screen {id → entry} map, and has each row listen to only its own id to cut the cascade.
Why it's needed
Immutable updates (structural sharing) hand out new object references to ancestor nodes too, not just the changed leaf. If you pass the tree down via props or a Context value, every row on the path up to the root re-renders on each typed character. React.memo blocks re-renders only when that row's entry reference is stable — if ancestor props change, memo is defeated too. Input latency in nested lists, outliners, and tree editors comes from here.
How it works
- Write model: the immutable tree is the source of truth — sorting, undo/redo, and serialization are handled here.
- Projection: on each patch,
syncTreeupdates the{id → RenderEntry}map. - Value-equality stabilization:
deriveEntryreuses the previous entry object reference when the content is equal (similar to reselect'sresultEqualityCheck). A ref-memo that keys only on the input node ref misses at every ancestor, leaving O(depth) intact. - Per-id subscription: each row uses
useBlockEntry(id)= an external store with only that id's listeners +useSyncExternalStore. childrenIdsstabilization: when only descendant text changes, the parent's id-list subscribers keep an identical snapshot.- Publish choke point: a synchronous
syncTreeright before ReactsetState— no notify during render; auseLayoutEffectsync races into a null render because the entry doesn't exist yet before the inserted node mounts. - Derived-state projection: sibling ordinal, column ratio, etc. are projected in advance onto the entry at reconcile time — so rows that read only child props don't go stale.
Practical application
- Suited to UIs with deep nesting + frequent leaf edits, like trees, outliners, and comment threads.
- Don't put the whole tree in Context; keep runtime context to stable handlers and meta only.
- On move (indent/outdent), GC is not "delete if not in the parent's children list" but drop only when it's absent from the entire next tree's id set.
- Pin regressions with a per-id render-count test that keeps far-branch increments at 0.
Trade-offs
- Map sync, ordinal projection, and GC logic are more complex than props drilling.
- The more derived state (numbering, ratios), the more projection rules pile up.
- It can be over-engineering for small trees where Zustand selectors or the React Compiler alone suffice.
- If undo/redo requires snapshot ref identity, the write model must stay an immutable object throughout.
When not to use it
- Shallow forms (5 fields or fewer) — the projection cost outweighs the gain.
- A read-only view where the whole tree changes at once — simple memo is enough.
- A static list rendered by server components only.
Common mistakes
- Memoizing the entry cache only by input node ref → misses at every structurally-shared ancestor.
- Putting external store sync only in
useLayoutEffect→ null render right after insert/redo. - Parent-level GC on indent that deletes the moved node.
- Gutter/ARIA reading only prop ratios → stale after resize.
- Pushing the whole entry map through a wide Context value → losing the per-id subscription benefit.
Related concepts
- react-context-render-granularity — if the container owns live state, re-renders beyond mount are unavoidable
- usesyncexternalstore-snapshot-identity — snapshot
Object.is/ monotonic revision - two-stack-inverse-undo — immutable tree + patch choke point
- list-virtualization-windowing — complements render/DOM savings when row count is high