What this post covers
When you build tree UIs in React — nested documents, comments, outliners and the like — typing into a single deep line can redraw the entire screen. The cause is that an immutable tree patch sends new references all the way up to the ancestors, so every row along the path re-renders. Instead of "make it feel fast," I stuck to the order of nailing the render count down as a contract and only then changing the architecture. I organize per task what situation each was in, what it aimed for, how it was solved, and what I learned. Implementation details continue in the derived knowledge docs below.
For background, the screen is a tree of nested blocks. A block inside a section, another block inside that block… nesting down to several levels deep. The data is managed as an immutable tree, so fixing one leaf (the bottommost block) gives not only that block but its parent, grandparent, …, root new object references. This is not a bug — it's the normal behavior of structural sharing. The problem is the rendering approach: if you pass the whole tree (or a large chunk) down via props or Context, every row along the path re-renders based on the ancestor whose reference changed. It's close to flipping one light switch and having every light in the house wired to the same line flicker along with it.
Day summary
| Task | What I wanted | What I did | Result |
|---|---|---|---|
| Measure | Pin far sibling/ancestor extra re-renders to 0 when only a deep block changes | Wrapped the row wrapper with jest.mock to record per-id render counts, baseline snapshot after focus | Locked +0 vs. the 5-level-deep far branch baseline as a test |
| Per row | Cut a single-block edit cascade from O(depth) → O(1) | Split write model (immutable tree) / read model ({id→entry}), each row subscribes only to its own id via useSyncExternalStore, value-equality cache | Only the 1 changed-id row re-renders, ancestors stay at baseline |
| Container | Stop the root container from spinning on every commit | Made the Context value an immutable store, subscribed to each frequently-changing slice with useSyncExternalStore | Root down to a single mount, render === 0 verified |
| ref timing | Safely write the empty-doc flag onto the parent ref DOM | Deferred useLayoutEffect → useEffect because of child-first commit | Fixed the bug where the attribute wasn't attaching due to ref.current === null |
1. Pin re-render isolation with a test first
Background knowledge (concepts)
- Re-render: React calling the component function again to sync the screen.
- Immutable update: A style where fixing data creates a new object rather than mutating the existing one. Good for undo/redo, but once a reference changes React considers it "changed."
What the situation was
Before optimizing, I needed to nail down "when only a deep node changes, are far siblings at 0 extra renders?" as a contract so I could see the improvement as a number. The existing tests covered flat 3-sibling isolation and recursive depth separately, but the case that watches "nested tree + value change + far-branch isolation" at the same time was missing.
Core work
I filled just that intersection with a new test.
- Wrap the row wrapper with
jest.mockto record per-id render counts into an externalMap. The original component still runs, so behavior is preserved. - Instead of nondeterministic input like typing, use a one-shot action such as splitting a line with Enter.
- Take the baseline right after focusing a block. This is to subtract the single re-render that happens when a static view switches to the editor from the "value change" measurement.
As a result, I locked as a test that the 5-level-deep far branch is +0 versus baseline, and afterward, once the O(1) read model was in, I could immediately see the improvement by whether ancestor rows stayed at baseline.
Lessons
- Before optimizing, pin the "bad state" with a test. If you first make "siblings must not redraw" RED, then GREEN after the refactor becomes the proof.
→ React render count isolation testing
2. O(1) read model — CQRS projection
Background knowledge (concepts)
- props / Context: Values a parent passes to children. When something changes wholesale up top, the bottom tends to re-render along with it.
- CQRS projection: A pattern that separates the write model from the read model. Here, the read-only projection is split into UI units.
What the situation was
When fixing one block, I had to cut the O(depth) cascade — where every ancestor row along the path re-renders — down to O(1).
Core work
I separated the write model (immutable tree, the source of truth for undo/redo and serialization) from the read model (a normalized map for the screen, {id → entry}).
| Axis | Content |
|---|---|
| Subscription | Each row subscribes to only its own id via useSyncExternalStore |
| Cache | Value-equality — if the content is the same, reuse the previous entry's object reference (an input-ref memo misses at every ancestor) |
| sync | Synchronous syncTree at the choke point right before React setState (useLayoutEffect can make a new block look null due to an insertion race) |
| Derived | Order numbers, column ratios, etc. are precomputed into the entry at reconcile time |
| GC | Instead of clearing a moved id per parent, clean up based on the id set of the next tree |
After the refactor, in the same render-count test the ancestors also stayed at baseline, so per-row O(1) was proven as a number.
Lessons
- Immutable trees and React props fit well, but passing the whole thing down makes them shake together. Split the write model and the read model, and have each row subscribe only to its own id.
- The heart of memoization is "same output → same reference." If you key only on the input node ref, it misses at every ancestor.
→ Normalized render entry projection
3. Zero container re-renders — Context as an immutable store
Background knowledge (concepts)
- external store subscription: Keep the Context value as an immutable store and subscribe with
useSyncExternalStoreonly to the frequently-changing slices. It skips re-renders via anObject.issnapshot comparison.
What the situation was
Even after removing the row cascade, if the topmost container holds live doc state with useState, the Provider re-renders on every input, blur, and toggle. It was the final tail of "the rows don't shake, but the shell spins every time."
Core work
I placed an external store subscriber for each frequently-changing slice.
- Root child list → subscribe only to the root entry's
childrenIds(changes only when the structure changes) - Table of contents / derived stats → immutable
DocContentStore+useSyncExternalStore - Empty-doc flag → a leaf that
return nulls when there's no content draws adata-*attribute onto the containerref
Since undo/redo needs a different doc object reference for every snapshot, I kept the write model that creates a new doc per patch, and only routed the Context value through a fixed store. As a result the root dropped to a single-mount level and render === 0 was verified.
Lessons
- A Context Provider also re-renders if it owns state. Pull the frequently-changing slices out into a fixed store +
useSyncExternalStore.
→ React Context render granularity · useSyncExternalStore snapshot identity
4. ref timing — child-first commit
Background knowledge (concepts)
- child-first commit: React commit usually attaches child DOM first. So when a child's layout effect runs, the parent ref may not be connected yet.
What the situation was
When I tried to write the empty-doc state from a child useLayoutEffect onto the parent ref DOM, no attribute attached at all. The log showed the store snapshot was there but ref.current === null.
Core work
Deferring by one tick with useEffect makes it attach safely, since it's after commit finishes. The tradeoff is that the attribute reflection can be up to one frame late. As a result, the bug where the attribute wasn't attaching due to ref.current === null was resolved.
Lessons
- With ref timing, the "faster effect" doesn't always win. Writes to ancestor DOM should be deferred with
useEffect, or use your own ref.
→ React commit child-first ref timing
Derived knowledge
Concept docs from this TIL you can read more deeply.
- Normalized render entry projection — immutable tree →
{id → entry}projection and per-id subscription - React render count isolation testing — contracting isolation with a jest mock probe and baseline snapshot
- React Context render granularity — why a broad Context value breaks memo, and store separation
- useSyncExternalStore snapshot identity —
Object.issnapshot comparison and reference stabilization - React commit child-first ref timing — layout effect vs. effect and ref attachment order