定義
アウトライナーやネストしたコメントのような深いツリーUIで、一番下の1行だけを直したのに画面全体が再描画されるとき、その再レンダー範囲を1行に狭めるために使うパターンである。Normalized render entry projection(正規化レンダーエントリ投影)は、不変ツリー(またはグラフ)をwrite model(値を変える原本、本物のデータ)として置き、画面レンダー専用に正規化マップ {id → entry}(ネストを平らに展開してidで直接引けるようにしたフラットなマップ)を派生read-model(読み取り専用のコピー)として別に維持する。そして各行が自分のidだけをuseSyncExternalStore(React外の状態をコンポーネントに購読させるフック)で購読するようにして、ノード1つを編集するとき、再レンダー対象を経路長に比例(O(depth))していたものから1行(O(1)) に減らす。データを変える側(write)と読んで表示する側(read)を分けるCQRS(Command Query Responsibility Segregation)の発想から、読み取りモデルをUI購読単位に細かく刻んだ形である。
文脈で理解する
ネストブロックやコメントスレッドのようなツリーUIでは、一番下の1行だけ直しても、不変更新のせいで親・祖父まで新しいオブジェクトになる。ツリー全体をpropsで下ろすと、「1部屋の電灯だけ点けたのに同じ配線の別部屋まで点滅する」ように、遠い兄弟・祖先の行まで再レンダーされる。このパターンは原本ツリーはそのままにして、画面用の{id → entry}マップだけを更新したあと、各行が自分のidだけを聞くようにしてcascadeを断つ。
なぜ必要か
不変更新(structural sharing)は、変更されたリーフだけでなく祖先ノードにも新しいオブジェクト参照を渡す。ツリーをpropsやContext valueで下ろすと、1フィールドをタイプするたびにルートまで経路上のすべての行が再レンダーされる。React.memoはその行のentry参照が安定しているときだけ止める — 祖先のpropsが変わればmemoも無力化される。ネストリスト・アウトライナー・ツリーエディタの入力遅延はここから来る。
動作原理
- Write model: 不変ツリーがsource of truth — ソート、undo/redo、直列化はここで処理する。
- Projection: パッチごとに
syncTreeが{id → RenderEntry}マップを更新する。 - 値-等価の安定化:
deriveEntryが内容が同じなら以前のentryオブジェクト参照を再利用する(reselectのresultEqualityCheckと類似)。入力ノードのrefだけをキーにするref-memoは祖先ごとにmissして、O(depth)がそのまま残る。 - Id別購読: 各行は
useBlockEntry(id)= そのidのリスナーだけを持つ外部ストア +useSyncExternalStore。 childrenIdsの安定化: 子孫テキストだけが変わるとき、親のidリスト購読者はスナップショットが同一に保たれる。- Publish choke point: React
setStateの直前に同期syncTree— レンダー中のnotify禁止。useLayoutEffectsyncは、挿入ノードがmountされる前にentryが存在せず、null renderのraceになる。 - 派生状態の投影: 兄弟のordinal、column ratioなどはreconcile時にentryへあらかじめ投影する — 子のpropsだけを読む行がstaleにならないように。
実務適用
- ツリー・アウトライナー・コメントスレッドのような深いネスト + 頻繁なリーフ編集のUIに適する。
- Contextにツリー全体を入れず、runtime contextはstableなhandler・metaだけに保つ。
- 移動(indent/outdent)時のGCは「親の子リストになければ削除」ではなく、次のツリー全体のid集合にないときだけdropする。
- 回帰はid別のrender countテストで、far branchの増加0を固定する。
トレードオフ
- マップ同期・ordinal投影・GCロジックが、props drillingに比べて複雑である。
- 派生状態(番号付け、比率)が多いほどprojectionルールが増える。
- Zustand selectorやReact Compilerだけで十分な小さいツリーには過剰な設計になりうる。
- undo/redoがスナップショットref同一性を要求するなら、write modelは不変オブジェクトのまま維持し続けなければならない。
使ってはいけない場合
- 浅いフォーム(フィールド5個以下) — projectionのコストが利得より大きい。
- ツリー全体が一度に変わる読み取り専用ビュー — 単純なmemoで十分。
- サーバーコンポーネントだけでレンダーされる静的な一覧。
よくある間違い
- entryキャッシュを入力ノードのrefだけでmemo → 構造共有の祖先で毎回miss。
- 外部ストアのsyncを
useLayoutEffectだけに置く → 挿入/redo直後にnull render。 - indent時に親単位のGCで移動されたノードを削除。
- gutter・ARIAがpropsの比率だけを読み、リサイズ後にstale。
- 広いContext valueでentryマップ全体をpush → id購読の利点を失う。
関連概念
- react-context-render-granularity — コンテナがlive stateを所有すると、mount以外の再レンダーが不可避
- usesyncexternalstore-snapshot-identity — スナップショット
Object.is・単調なrevision - two-stack-inverse-undo — 不変ツリー + パッチのchoke point
- list-virtualization-windowing — 行数が多いときのレンダー・DOM削減を補完