⚛️ React Boundary
基礎 — アーキテクチャ

🌲 コンポーネントツリーとは何か?

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つを区別しましょう。

3種類のツリーの対応関係
① JSXツリー(ソースコード上の概念)

開発者がコードに書くコンポーネントの親子関係。実際のデータ構造ではなく概念的なもの。

<App> → <Header />, <Main> → <List />, <Footer />
↓ render時に生成(毎レンダリング)
② ReactElementツリー(一時的なオブジェクト)

各コンポーネント関数が返すJSXから生成されるオブジェクトツリー。Reactへの「描画指示書」。使い捨て。

{ type, props, key, ref, $$typeof } の入れ子構造
↓ Reconciliationで比較・更新
③ Fiberツリー(永続的な内部データ構造)

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>
  );
}
ReactElementツリー(render時に生成)
{
  type: "div",
  props: {
    className: "app",
    children: [
      {
        type: Header,
        props: { title: "Todos" }
      },
      {
        type: TodoList,
        props: { items: [] }
      }
    ]
  }
}

// ← 毎レンダリングで新しく生成
// ← stateは持たない(描画指示書)
Fiberツリー(内部に永続)
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ツリーを保持します。この設計をダブルバッファリングと呼びます。

ダブルバッファリングの詳細
current tree
  • 現在ブラウザに表示されている状態
  • commit phaseが完了したツリー
  • FiberRoot.currentが指す先
  • 各FiberのalternateはWIPを指す
work-in-progress tree (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)に対応しています。

深さ優先走査の順序
① App
② Layout
③ Header
④ Logo ← リーフノード(childなし)→ returnで戻る
⑤ Nav ← siblingで横移動
⑥ Main ← Headerのsiblingで横移動(returnでLayoutに戻った後)
⑦ Article ← リーフノード
⑧ Footer ← Layoutのsibling
// 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の役割

beginWork

ノードに入るとき(下に進むとき)。コンポーネント関数を呼び出し、ReactElementを受け取り、子のFiberを生成または更新する。差分検出(Reconciliation)はここで行われる。

completeWork

リーフノードに達したとき、または子の処理が完了して戻ってきたとき。DOM要素の生成、エフェクトリストへの追加などを行う。subtreeFlagsを集約して親に渡す。

📐 クラスコンポーネントvs関数コンポーネントとFiber

クラスコンポーネントと関数コンポーネントでは、Fiberとの関係に重要な違いがあります。

クラスvs関数コンポーネントと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をマウントします。

keyによる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を切り出す(State Colocation)
// ✅ 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レベルで再レンダリングを最小化できる

関連記事