この記事で扱うこと
Reactでネストされたドキュメント・コメント・アウトライナーのようなツリーUIを作っていると、奥深くの一行にタイピングしただけで画面全体が再描画されることが起きる。原因は、イミュータブルなツリーpatchが祖先まで新しい参照を下ろし、経路上の行がすべて再描画されることだ。「速く感じさせる」ではなく、レンダー回数を契約として打ち込んでからアーキテクチャを変える、という順序を守った。各作業がどんな状況で何を目標にどう解決され、何を学んだかを作業ごとに整理する。実装の詳細は下の派生知識ドキュメントへ続く。
背景として、画面はブロックがネストしたツリーだ。セクションの中にブロック、ブロックの中にまたブロック…最大で数段の深さまで続く。データはイミュータブルなツリーで管理するので、リーフ(一番下のブロック)を一つ直すと、そのブロックだけでなく親・祖父・…・ルートまで新しいオブジェクト参照を受け取る。これはバグではなくstructural sharing(構造共有)の正常な動作だ。問題はレンダリングの方式だ。ツリー全体(または大きな塊)をpropsやContextで下に下ろすと、参照が変わった祖先を基準に経路上の行がすべて再描画される。一つの電灯だけを点けたのに、同じ電線につながった家全体の電灯が一緒に点滅する状況に近い。
一日のまとめ
| 作業 | 何をしたかったか | やったこと | 結果 |
|---|---|---|---|
| 計測 | 深いブロックだけ変えたとき遠い兄弟・祖先の追加再レンダー0回を固定 | 行ラッパーをjest.mockで包んでid別レンダー回数を記録、focus後にbaselineスナップショット | 5段深さのfar branch baseline比+0をテストとして固定 |
| 行単位 | 一ブロック修正のcascadeをO(depth) → O(1)に | write model(イミュータブルなツリー) / read model({id→entry})を分離、行ごとに自分のidだけuseSyncExternalStore購読、値-等価キャッシュ | 変更されたid 1行だけ再レンダー、祖先もbaseline維持 |
| コンテナ | ルートコンテナがcommitのたびに回るのを遮断 | Context valueをイミュータブルなstoreに、頻繁に変わる断片ごとにuseSyncExternalStore購読 | ルートmount 1回程度、render === 0検証 |
| refタイミング | 空ドキュメントフラグを親ref DOMに安全に書く | child-first commitのためuseLayoutEffect → useEffectに遅らせる | ref.current === nullで属性が付かなかったバグを解消 |
1. 再レンダー隔離をまずテストで固定
背景知識 (概念)
- 再レンダー: Reactがコンポーネント関数を再び呼び出して画面を合わせること。
- イミュータブル更新: データを直すとき既存のオブジェクトを変えず新しいオブジェクトを作る方式。undo/redoに有利だが、参照が変わるとReactは「変わった」とみなす。
どんな状況だったか
最適化の前に「深いノードだけ変えたとき遠い兄弟は0回追加レンダーか」を契約として打ち込んでおけば、改善の有無を数字で見られる。既存のテストは平らな3兄弟の隔離と再帰の深さをそれぞれ扱っていたが、**「ネストしたツリー + 値変更 + 遠い枝の隔離」**を同時に見るケースが空いていた。
中心の作業
その交差部分だけを新しいテストで埋めた。
- 行ラッパーを
jest.mockで包んでid別レンダー回数を外部のMapに記録する。元のコンポーネントはそのまま実行されるので動作は維持される。 - タイピングのような非決定的な入力ではなく、Enterで行を分けるような一度で終わるアクションを使う。
- ブロックをフォーカスした直後にbaselineを撮る。staticビュー → エディタに変わるときに生じる1回の再レンダーを「値変更」の計測から引くためだ。
結果として5段深さのfar branchがbaseline比+0であることをテストとして固定し、その後O(1)のread modelを入れたとき、祖先の行もbaselineのままかで改善の有無をすぐ見られるようになった。
教訓
- 最適化の前にテストで「悪い状態」を固定せよ。「兄弟は描画されてはならない」をまずREDにしておけば、リファクタ後のGREENがそのまま証明になる。
→ React render count isolation testing
2. O(1) read-model — CQRS projection
背景知識 (概念)
- props / Context: 親が子に渡す値。上でまるごと変わると下もつられて再レンダーしやすい。
- CQRS projection: 書き込み用モデルと読み取り用モデルを分離するパターン。ここでは読み取り専用のprojectionをUI単位で刻む。
どんな状況だったか
一ブロックを直すとき経路上の祖先の行がすべて再レンダーされるO(depth)のcascadeをO(1)に減らす必要があった。
中心の作業
write model(イミュータブルなツリー、undo/redo・直列化の基準)とread model(画面用の正規化マップ{id → entry})を分離した。
| 軸 | 内容 |
|---|---|
| 購読 | 各行が自分のidだけuseSyncExternalStoreで購読 |
| キャッシュ | 値-等価 — 内容が同じなら以前のentryのオブジェクト参照を再利用 (入力refのmemoは祖先ごとにmiss) |
| sync | React setState直前のchoke pointで同期syncTree (useLayoutEffectは挿入raceで新しいブロックがnullに見えることがある) |
| 派生 | 順序番号・列比率などはreconcile時にentryに前もって計算して入れる |
| GC | 移動したidを親単位で消すのではなく、次のツリーのid集合を基準に整理 |
リファクタ後、同じrender countテストで祖先もbaseline維持となり、行単位のO(1)が数字で証明された。
教訓
- イミュータブルなツリーとReact propsはよく合うが、まるごと下ろすと一緒に揺れる。write modelとread modelを分け、行ごとに自分のidだけ購読させる。
- メモ化は「出力が同じなら同じ参照」が核心だ。入力ノードのrefだけをキーに使うと祖先ごとにmissする。
→ Normalized render entry projection
3. コンテナ再レンダー0 — Contextをイミュータブルなstoreに
背景知識 (概念)
- external store購読: Context valueをイミュータブルなstoreにして
useSyncExternalStoreで頻繁に変わる断片だけ購読する方式。Object.isスナップショット比較で再レンダーをスキップする。
どんな状況だったか
行cascadeを無くした後も、最上段のコンテナがlive doc stateをuseStateで持っていると、入力・blur・トグルのたびにProviderが再描画される。「行は揺れないのに殻は毎回回る」最後の尻尾だった。
中心の作業
頻繁に変わる断片ごとにexternal storeの購読者を置いた。
- ルートの子リスト → root entryの
childrenIdsだけ購読 (構造が変わるときだけ変わる) - 目次・派生統計 → イミュータブルな
DocContentStore+useSyncExternalStore - 空ドキュメントフラグ → 内容がないとき
return nullするリーフがコンテナのrefにdata-*属性を描く
undo/redoはスナップショットごとに異なるdocオブジェクト参照が必要なので、パッチごとに新しいdocを作るwrite modelは維持し、Contextのvalueだけを固定storeで迂回した。結果としてルートがmount 1回程度まで下がりrender === 0が検証された。
教訓
- Context Providerもstateを持てば再レンダーされる。頻繁に変わる断片は固定されたstore +
useSyncExternalStoreに抜き出す。
→ React Context render granularity · useSyncExternalStore スナップショット同一性
4. refタイミング — child-first commit
背景知識 (概念)
- child-first commit: React commitは通常、子DOMから付ける。だから子のlayout effectが回るとき親のrefがまだ接続されていないことがある。
どんな状況だったか
空ドキュメントかどうかを子のuseLayoutEffectで親のref DOMに書こうとしたら、何の属性も付かない現象があった。storeのスナップショットはあるのにref.current === nullのログが残った。
中心の作業
useEffectで1ティック遅らせるとcommitが終わった後なので安全に付く。代わりに属性の反映が最大1フレーム遅れうるというtradeoffがある。結果としてref.current === nullで属性が付かなかったバグが解消された。
教訓
- refタイミングは「速いeffect」が常に勝つわけではない。祖先DOMに書くことは
useEffectに遅らせるか、自分のrefを使う。
→ React commit child-first ref timing
派生知識
このTILからさらに深く読める概念ドキュメントだ。
- Normalized render entry projection — イミュータブルなツリー →
{id → entry}projectionとid別購読 - React render count isolation testing — jest mockプローブとbaselineスナップショットで隔離を契約化
- React Context render granularity — 広いContext valueがmemoを壊す理由とstore分離
- useSyncExternalStore スナップショット同一性 —
Object.isスナップショット比較と参照の安定化 - React commit child-first ref timing — layout effect vs effectとref付着の順序