🌲 コンポーネントツリーとは何か?
Reactアプリはどのようなデータ構造として管理されているのか? コンポーネントツリー、ReactElementツリー、Fiberツリーの関係を体系的に解説します。
🤔 問題提起
Reactを使う中で、こんな疑問を持ったことはないでしょうか?
- 「Reactはコンポーネントをどんなデータとして内部で管理しているのか?」
- 「keyを変えるとコンポーネントが初期化されると言うが、なぜか?」
- 「current treeとwork-in-progress treeとは何か?なぜ2つあるのか?」
- 「マウントとアンマウントの実体は何か?内部で何が起きているのか?」
- 「パフォーマンス最適化を考える上でツリー構造を知ることがなぜ重要なのか?」
これらの問いに答えるには、Reactの内部データ構造——特にFiberツリー——を理解する必要があります。 Reactのすべての機能(再レンダリング、Reconciliation、Concurrent Mode)はこのツリー構造を基盤にしています。
🎯 結論から言う
ReactはコンポーネントをFiberノードのツリーとして管理します。
このFiberツリーがReconciliation(差分検出)、スケジューリング、エフェクト管理の基盤です。 常にcurrent treeとwork-in-progress treeの2つが存在します。
🗺️ 3種類の「ツリー」を整理する
Reactの文脈では「ツリー」という言葉が複数の意味で使われます。まず3つを区別しましょう。
開発者がコードに書くコンポーネントの親子関係。実際のデータ構造ではなく概念的なもの。
<App> → <Header />, <Main> → <List />, <Footer /> 各コンポーネント関数が返すJSXから生成されるオブジェクトツリー。Reactへの「描画指示書」。使い捨て。
{ type, props, key, ref, $$typeof } の入れ子構造 Reactが内部で管理するツリー。state、エフェクト、スケジューリング情報を保持。再利用される。
Fiber { type, stateNode, child, sibling, return, memoizedState, ... } 重要な違い:ReactElementは毎レンダリングで新しく生成される「使い捨て」のオブジェクトです。 一方Fiberは再利用される「永続的」なオブジェクトで、コンポーネントの存続期間中ずっと存在します。 これがuseStateでstateが保持され続ける仕組みの根拠です。
🧱 ReactElementツリーとFiberツリーの違い
具体的なコードでこの違いを見ていきます。
function TodoApp() {
const [todos, setTodos] = useState([]);
return (
<div className="app">
<Header title="Todos" />
<TodoList items={todos} />
</div>
);
}
function Header({ title }) {
return <h1>{title}</h1>;
}
function TodoList({ items }) {
return (
<ul>
{items.map(item => <TodoItem key={item.id} item={item} />)}
</ul>
);
} {
type: "div",
props: {
className: "app",
children: [
{
type: Header,
props: { title: "Todos" }
},
{
type: TodoList,
props: { items: [] }
}
]
}
}
// ← 毎レンダリングで新しく生成
// ← stateは持たない(描画指示書) Fiber(TodoApp) {
memoizedState: [[], setter]
child → Fiber("div") {
child → Fiber(Header) {
memoizedProps: {title}
sibling → Fiber(TodoList)
}
}
}
// ← コンポーネントが存在する限り保持
// ← stateはここに格納される
// ← alternate(前回のFiber)を持つ Fiberが保持する情報
interface Fiber {
// コンポーネントの種類(関数、クラス、DOM要素など)
tag: WorkTag;
type: string | Function | null;
// DOM要素やコンポーネントインスタンスへの参照
stateNode: any;
// ツリーの連結(child-first 深さ優先)
child: Fiber | null; // 最初の子
sibling: Fiber | null; // 次の兄弟
return: Fiber | null; // 親(returnは戻り先の意味)
index: number; // 兄弟の中の順序
// Props
pendingProps: Props; // 処理中のprops
memoizedProps: Props; // 前回確定したprops
// State(Hookのリンクリスト)
memoizedState: any; // useState等のstateチェーン
// スケジューリング
lanes: Lanes; // このFiberが持つ更新の優先度
childLanes: Lanes; // 子孫の更新優先度
// 差分フラグ(挿入・更新・削除など)
flags: Flags;
subtreeFlags: Flags;
// ダブルバッファリング
alternate: Fiber | null; // 対応する反対側のツリーのFiber
} 🔄 ダブルバッファリング: current と work-in-progress
Reactは常に2つのFiberツリーを保持します。この設計をダブルバッファリングと呼びます。
- 現在ブラウザに表示されている状態
- commit phaseが完了したツリー
- FiberRoot.currentが指す先
- 各FiberのalternateはWIPを指す
- 現在構築中の新しいツリー
- render phaseで作られる
- 中断してもcurrentは壊れない
- 各FiberのalternateはCurrentを指す
// ダブルバッファリングのイメージ
// 初期状態(マウント後)
current: Fiber(App) ←── FiberRoot.current
alternate ──→ null(まだWIPなし)
// 更新開始
current: Fiber(App) {memoizedState: 0}
alternate ──→ WIP Fiber(App) {pendingProps: ...}
wip: Fiber(App) ←── 構築中
// commit phase後(swap)
current: 旧WIP Fiber(App) ←── FiberRoot.currentが切り替わる
alternate ──→ 旧current(次回のWIPとして再利用)
// → alternate を使い回すことでGCの負荷を減らしている なぜダブルバッファリングが必要か
render phaseは中断・再開が可能(Concurrent Mode)です。 その間もユーザーは現在のUIを見続けます。 WIPツリーが完成するまでcurrent treeは変わらないため、 計算中に中途半端な状態がユーザーに見えることはありません。
🚶 ツリーの走査順序(深さ優先)
ReactのReconciliationは深さ優先(depth-first)でFiberツリーを走査します。 これはFiberの連結リスト構造(child → sibling → return)に対応しています。
// ReactのworkLoop(簡略化)
function performUnitOfWork(unitOfWork: Fiber): Fiber | null {
// 1. このFiberの処理(beginWork)
// コンポーネント関数を呼び出し、子のFiberを生成
const next = beginWork(current, unitOfWork, renderLanes);
unitOfWork.memoizedProps = unitOfWork.pendingProps;
if (next === null) {
// 子がない(リーフノード)→ completeWorkで後処理
// その後siblingに移動、なければ親(return)に戻る
completeUnitOfWork(unitOfWork);
} else {
// 子がある → 子に進む(深さ優先)
workInProgress = next;
}
}
// 走査の優先順位
// 1. childがあれば child へ(深く進む)
// 2. childがなければ sibling へ(横に進む)
// 3. siblingもなければ return へ(上に戻る)
// 4. rootまで戻ったら終了 beginWorkとcompleteWorkの役割
ノードに入るとき(下に進むとき)。コンポーネント関数を呼び出し、ReactElementを受け取り、子のFiberを生成または更新する。差分検出(Reconciliation)はここで行われる。
リーフノードに達したとき、または子の処理が完了して戻ってきたとき。DOM要素の生成、エフェクトリストへの追加などを行う。subtreeFlagsを集約して親に渡す。
📐 クラスコンポーネントvs関数コンポーネントとFiber
クラスコンポーネントと関数コンポーネントでは、Fiberとの関係に重要な違いがあります。
class Counter extends React.Component {
state = { count: 0 };
render() {
return <div>{this.state.count}</div>;
}
}
// Fiberとの関係:
// Fiber.stateNode = Counter インスタンス
// ← new Counter(props) で生成
// Fiber.memoizedState はインスタンスには不使用
// stateはインスタンス(this.state)が持つ
// ライフサイクルメソッドはインスタンスから呼ばれる function Counter() {
const [count, setCount] = useState(0);
return <div>{count}</div>;
}
// Fiberとの関係:
// Fiber.stateNode = null
// ← インスタンスは存在しない
// Fiber.memoizedState = HookのLinked List
// { memoizedState: 0, // count
// queue: UpdateQueue,
// next: null }
// HooksはmemoizedStateチェーンに格納
// 呼び出し順序でHookが特定される なぜHooksは呼び出し順序に依存するのか: 関数コンポーネントにはインスタンスがないため、useStateなどのHookはFiber.memoizedStateという 連結リスト(Linked List)に順番に格納されます。 レンダリングのたびに同じ順序でHookを呼び出すことで、正しいstateに対応できます。 条件分岐でHookを呼んではいけない理由はこれです。
🌱 マウント・アンマウント・再レンダリングでFiberはどう変わるか
Fiberツリーはコンポーネントのライフサイクルに応じて変化します。 それぞれの操作でFiberに何が起きるかを詳しく見ます。
マウント(初回レンダリング)
コンポーネントが初めてDOMに追加されるとき。対応するFiberが新規作成されます。
// マウント時の流れ // 1. ReactDOM.createRoot(container).render(<App />) が呼ばれる // 2. FiberRoot(ルート管理オブジェクト)が作成される // 3. render phaseで各コンポーネントのFiberが新規作成(createFiber) // → Fiber.alternate = null(初回はWIPのみ) // 4. beginWorkでコンポーネント関数を呼び出し // → useState: memoizedStateに初期値がセットされる // → useEffect: エフェクトがFiberのupdateQueueに積まれる // 5. commit phaseでDOMに挿入(Placement フラグ) // 6. useLayoutEffect が同期的に実行される // 7. ブラウザがペイント // 8. useEffect が非同期で実行される
再レンダリング(更新)
stateやpropsが変わったとき。既存のFiberを再利用してWIPを作ります。
// 更新時の流れ // 1. setState(newValue) が呼ばれる // 2. 対象FiberのupdateQueueにUpdateオブジェクトが積まれる // 3. FiberRoot のpendingLanesにレーンがセットされ、スケジューリング // 4. render phaseで対象FiberのalternateとしてWIP Fiberを作成 // (createWorkInProgress で既存Fiberを再利用) // 5. beginWork でコンポーネント関数を再実行 // → useState: memoizedState は前回値を返し、pendingStateを適用 // → 新しいReactElementツリーと既存Fiberを比較(Reconciliation) // 6. 差分のあったFiberにflagsをセット(Update, Placement, Deletion等) // 7. commit phaseで変更のあったDOMのみ更新 // → React.memoの場合: props比較でスキップされたFiberは bailout // (WIPはcurrentの clone になり、子のbeginWorkをスキップ)
アンマウント(削除)
コンポーネントがDOMから削除されるとき。対応するFiberは破棄されます。
// アンマウント時の流れ
// 1. 親のrender結果からこのコンポーネントが消える
// 例: {isVisible && <Modal />} で isVisible が false になる
// 2. Reconciliation時: WIPツリーに対応するFiberがないことを検出
// → 旧Fiberに Deletion フラグをセット
// 3. commit phaseのbeforeMutationフェーズ:
// → getSnapshotBeforeUpdate(クラス)を呼ぶ
// 4. commit phaseのmutationフェーズ:
// → DOMからノードを削除
// → useLayoutEffect の cleanup 関数を実行
// → クラスの componentWillUnmount を実行
// 5. commit phaseのlayoutフェーズ後(非同期):
// → useEffect の cleanup 関数を実行
// 6. Fiberオブジェクトはガベージコレクションされる
// → memoizedState(Hooksの状態)もすべて破棄される 🔑 keyとFiberの紐付け
keyプロパティはReactがFiberを識別するための重要な情報です。
keyが変わると、Reactは同じ型のコンポーネントでも別のFiberとして扱い、
旧Fiberをアンマウントして新しいFiberをマウントします。
// ReconciliationでのFiber識別アルゴリズム(簡略版)
// 新しいReactElementリストと既存Fiberリストを比較する
function reconcileChildrenArray(returnFiber, currentFirstChild, newChildren) {
// まずkeyでマッピングを作る
const existingChildren = new Map();
let existingChild = currentFirstChild;
while (existingChild !== null) {
const key = existingChild.key !== null
? existingChild.key
: existingChild.index;
existingChildren.set(key, existingChild);
existingChild = existingChild.sibling;
}
// 新しい子リストをループ
for (let newIdx = 0; newIdx < newChildren.length; newIdx++) {
const newChild = newChildren[newIdx];
const key = newChild.key !== null ? newChild.key : newIdx;
const matchedFiber = existingChildren.get(key);
if (matchedFiber && matchedFiber.type === newChild.type) {
// ✅ keyもtypeも一致 → Fiberを再利用(Update)
existingChildren.delete(key);
// ... updateFiber()
} else {
// ❌ 一致しない → 新しいFiberを作成(Placement)
// matchedFiberがあれば Deletion フラグ付き
// ... createFiber()
}
}
// 残った既存Fiber → Deletionフラグ
existingChildren.forEach(child => deleteChild(returnFiber, child));
} keyを使ったstateリセットのテクニック
keyが変わるとFiberが破棄・再生成されるため、コンポーネントのstateを意図的にリセットするテクニックとして使えます。
function UserProfile({ userId }) {
// useEffect + fetchでデータをリセットするより
// keyを変えるだけでコンポーネント全体がリセットされる
return <ProfileContent key={userId} userId={userId} />;
}
function ProfileContent({ userId }) {
// userId が変わるたびにこのコンポーネントの
// Fiberが破棄・再生成される
// → すべてのuseStateが初期値に戻る
// → すべてのuseEffectがcleanupされ再実行される
const [activeTab, setActiveTab] = useState('overview');
const [data, setData] = useState(null);
// ...
} 💡 Tip:
ユーザーを切り替えたときにプロフィールデータが前のユーザーのものが一瞬表示される問題は、
key={userId}で解決できることが多いです。
インデックスをkeyにしてはいけない理由
リストアイテムにkeyとしてインデックスを使うと、順序が変わったときに ReactがFiberを誤って再利用し、stateが間違ったコンポーネントに残ります。
// ⚠️ 悪い例: インデックスをkeyに使う
// items = ['Alice', 'Bob', 'Charlie']
items.map((name, index) => <UserRow key={index} name={name} />)
// リストの先頭に 'Dave' を追加すると:
// 旧: key=0→Alice, key=1→Bob, key=2→Charlie
// 新: key=0→Dave, key=1→Alice, key=2→Bob, key=3→Charlie
// ReactはFiber key=0 を再利用 → Aliceのstateが Daveの行に引き継がれる!
// ✅ 良い例: 安定したIDをkeyに使う
items.map((user) => <UserRow key={user.id} user={user} />)
// ユーザーIDは追加・削除・並び替えをしても変わらない
// ReactはFiberを正確に識別できる 📊 ツリー構造とパフォーマンス最適化の関係
Fiberツリーの構造を理解することで、なぜコンポーネントの設計がパフォーマンスに影響するかがわかります。
// stateを上位コンポーネントに置いてしまうパターン
function App() {
// このstateが変わると App 以下全コンポーネントが再レンダリング
const [inputValue, setInputValue] = useState('');
return (
<div>
<input value={inputValue} onChange={e => setInputValue(e.target.value)} />
<HeavyVisualization data={data} /> {/* 毎回再レンダリング */}
<DataTable rows={rows} /> {/* 毎回再レンダリング */}
<Sidebar config={config} /> {/* 毎回再レンダリング */}
</div>
);
} // ✅ stateを使うコンポーネントに近いところに置く
function SearchInput() {
// このstateが変わっても SearchInput コンポーネントだけが再レンダリング
const [inputValue, setInputValue] = useState('');
return (
<input value={inputValue} onChange={e => setInputValue(e.target.value)} />
);
}
function App() {
// App 自体はstateを持たない → 再レンダリングされない
return (
<div>
<SearchInput /> {/* 独立して再レンダリング */}
<HeavyVisualization /> {/* 変化なし */}
<DataTable /> {/* 変化なし */}
<Sidebar /> {/* 変化なし */}
</div>
);
} 同じUIを実現するにも、コンポーネントツリーのどこにstateを置くかで、 再レンダリングの伝播範囲がまったく変わります。 State Colocation(状態の局所化)はFiberツリーを理解した上で実践する最も効果的な最適化です。
📌 まとめ
- ✓ Reactの内部ではJSXツリー・ReactElementツリー・Fiberツリーの3種類のツリーが関わる
- ✓ ReactElementは毎レンダリング生成される使い捨て。Fiberはコンポーネント存続期間中永続する
- ✓ Fiberはchild・sibling・returnの連結リストで構成されダブルバッファリングで管理される
- ✓ current treeが現在の画面。work-in-progress treeが構築中の次の画面
- ✓ 関数コンポーネントのstateはFiber.memoizedStateにHookのLinked Listとして格納される
- ✓ ReconciliationはtypeとkeyでFiberを識別し、再利用・新規作成・削除を決定する
- ✓ keyが変わるとFiberが破棄・再生成される。これはstateリセットに意図的に使える
- ✓ State Colocationでstateをツリー上で局所化するとFiberレベルで再レンダリングを最小化できる