1. Top
  2. React inside basics: learn from "build own react"

React inside basics: learn from "build own react"

2025/08/11

Build own React というブログ記事の内容に取り組んでみました。
この記事では実際の React のアーキテクチャに沿って、React を 1 から書いていきます。
最適化機能を実装していなかったり、完全に再現しているわけではありません。
取り組む中で React の内部実装の基礎知識を得ることができたので、その内容についてお話しします。
また、関数型コンポーネントを前提としています。

また、こちらの内容は React Osaka 2025 06 というイベントでもお話しさせていただきました。
よければそちらもご覧ください。

https://speakerdeck.com/ryounasso/react-inside-basics-learn-from-build-own-react

目次

React の内部の基礎知識

1. 仮想 DOM

React の基本的な考え方として、状態が変わるたびにコンポーネントを毎回実行して DOM を新しく構築するというものがあります。
ただし、これを実際の DOM 操作で実現するとパフォーマンス上の問題が発生します。
そこで、仮想 DOM を導入してパフォーマンスの問題を解決しています。
仮想 DOM は JavaScript オブジェクトの木構造になっています。

仮想 DOM の例を以下に示します。

{
  type: "div",
  props: {
    children: [
      {
        type: "p",
        props: {
          children: ["Count: ", state]
        }
      },
      {
        type: "button",
        props: {
          onClick: () => setState((c) => c + 1),
          children: "Count up"
        }
      }
    ]
  }
}

仮想 DOM オブジェクトの内容に沿って実 DOM を生成してレンダリングしています。

const container = document.getElementById("root")
// element: 仮想 DOM オブジェクト
Didact.render(element, container)

function render(element, container) {
  // element.type に合わせて実 DOM 要素を生成
  const dom =
    element.type == "TEXT_ELEMENT" ? document.createTextNode("") : document.createElement(element.type)

  const isProperty = key => key !== "children"
  Object.keys(element.props)
    .filter(isProperty)
    .forEach(name => {
      dom[name] = element.props[name]
    })

  element.props.children.forEach(child =>
    render(child, dom)
  )// 生成した DOM を追加
  container.appendChild(dom)
}

2. 作業単位の分割とスケジューリング

大きなコンポーネントだと仮想 DOM のツリーが大きくなり、レンダリング中にスレッドを長時間占有してしまいます。
この問題を解決するために React はレンダリング作業を小さな単位に分割しており、この単位を Fiber と呼んでいます。

Fiber の特徴は以下のとおりです。

  • 各 Fiber は 1 つの作業単位(要素)を表現しています
  • Fiber 同士は親・子・兄弟の参照を持つ連結リストになっています
  • alternate プロパティで前回のレンダリング結果を保持しています
  • 処理を分割できることで Fiber のスケジューリングが可能になります
    • 優先度の低い Fiber はブラウザのアイドル時間に処理します
    • 優先度の高い Fiber は早いタイミングで実行します

Fiber Tree
ref: https://pomb.us/build-your-own-react/

以下に Fiber の例を示しています。

{
  type: "div",
  props: {
    children: [pFiber, buttonFiber]
  },
  dom: div, // 実際の DOM 要素への参照
  parent: counterFiber, // 親 Fiber
  child: pFiber, // 第一の子要素の Fiber
  sibling: null, // 同じ親を持つ要素の Fiber
  alternate: null, // 前回のレンダリング結果を保持する Fiber
  effectTag: "UPDATE" // DOM 操作のタイプ
}

// button Fiber
{
  type: "button",
  props: {
    onClick: () => setState((c) => c + 1),
    children: ["Count up"]
  },
  dom: button, // 実際の DOM 要素への参照
  parent: divFiber,
  child: buttonTextFiber, // ボタンテキストの Fiber
  sibling: null,
  alternate: oldButtonFiber,
  effectTag: "UPDATE"
}

3. レンダーとコミットの分離

Fiber によって優先度の高い処理を優先的に行えるようになりました。
例えば、ユーザーの入力処理が割り込んだ場合でも UI の不完全な状態で表示されないようにする必要があります。
そこで React は「レンダー」と「コミット」の 2 フェーズに分離することで問題を解決しています。

  • レンダーフェーズ:
    • 仮想 DOM の差分計算を行います(中断可能です)
    • DOM 操作は一切行いません
  • コミットフェーズ:
    • 差分計算完了後に実行します(中断不可です)
    • まとめて DOM 操作を実行します

登場するグローバル変数の説明

  • nextUnitOfWork: 次に処理すべき Fiber
  • wipRoot: 作業中の Fiber のルート
  • currentRoot: 前回コミットされた Fiber ツリー
  • deletions: 削除すべき Fiber のリスト
レンダリングフェーズ

レンダリングフェーズの処理の概要は以下の通りです。

function workLoop(deadline) {
  // shouldYield: ブラウザにレンダリング制御を戻すべきかを示すフラグ
  while (nextUnitOfWork && !shouldYield) {
    // performUnitOfWork: 1つの Fiber の処理と次の Fiberの取得
    nextUnitOfWork = performUnitOfWork(nextUnitOfWork);

    // 残された時間が1ms未満になったら中断。高優先度タスク(入力処理やアニメーション)に道を譲る
    shouldYield = deadline.timeRemaining() < 1;
  }

  // === レンダーフェーズ完了時の処理 ===
  if (!nextUnitOfWork && wipRoot) {
    // レンダーフェーズで計算した変更を実 DOM に適用
    commitRoot();
  }
}
コミットフェーズ

コミットフェーズの処理の概要は以下の通りです。

function commitRoot() {
  // deletions: 削除すべきノードのリスト
  // 削除操作を最初に行う
  deletions.forEach(commitWork);

  // wipRoot.child: ルートファイバーの子から始めて再帰的に全ての変更をコミット
  commitWork(wipRoot.child);

  // 次回の差分計算のために現在のルートを完了したルートで更新
  currentRoot = wipRoot; // currentRoot: 前回コミットされたファイバーツリー

  // 作業用ルートをクリア(コミット完了の印)
  wipRoot = null;
}

コミット作業を行います。すべてのノードを DOM に再帰的に追加します。

function commitWork(fiber) {
  if (!fiber) {
    return;
  }

  let domParentFiber = fiber.parent;
  while (!domParentFiber.dom) {
    domParentFiber = domParentFiber.parent;
  }
  const domParent = domParentFiber.dom;

  if (fiber.effectTag === "PLACEMENT" && fiber.dom != null) {
    domParent.appendChild(fiber.dom);
  } else if (fiber.effectTag === "UPDATE" && fiber.dom != null) {
    updateDom(fiber.dom, fiber.alternate.props, fiber.props);
  } else if (fiber.effectTag === "DELETION") {
    commitDeletion(fiber, domParent);
  }

  commitWork(fiber.child);
  commitWork(fiber.sibling);
}

4. Reconciliation

Reconciliation とは、DOM にレンダリングしたい内容と、前回のレンダリングの内容を比較して差分を検知する処理です。
比較した結果、DOM に適用すべき変更があるかどうかを確認します。

適用すべき変更内容の確認方法

  • 古い Fiber と新しい Fiber の要素が同じタイプ = 更新
  • 古い Fiber と新しい Fiber のタイプが異なり、新しい要素がある = 新規追加
  • 古い Fiber と新しい Fiber のタイプが異なり、古い Fiber がある = 削除

Reconciliation の処理

// elements: 新しくレンダリングしたい要素の配列
function reconcileChildren(wipFiber, elements) {
  let oldFiber = wipFiber.alternate?.child;

  // 新しい Fiber と今の Fiber を比較する
  for (let i = 0; i < elements.length || oldFiber != null; i++) {
    const element = elements[i];
    let newFiber = null;
    const sameType = oldFiber && element && element.type == oldFiber.type;

    if (sameType) {
      // update the node
      newFiber = {
        type: oldFiber.type,
        props: element.props,
        dom: oldFiber.dom,
        parent: wipFiber,
        alternate: oldFiber,
        effectTag: "UPDATE",
      };
    } else if (element) {
      // add this node
      newFiber = {
        type: element.type,
        props: element.props,
        dom: null,
        parent: wipFiber,
        alternate: null,
        effectTag: "PLACEMENT",
      };
    } else if (oldFiber) {
      oldFiber.effectTag = "DELETION";
      deletions.push(oldFiber);
    }

    if (oldFiber) {
      oldFiber = oldFiber.sibling;
    }

    if (index === 0) {
      wipFiber.child = newFiber;
    } else {
      prevSibling.sibling = newFiber;
    }

    prevSibling = newFiber;
    index++;
  }
}

例えば、カウンターが 1 から 2 に変わった場合

<!-- 古い要素 -->
<p>カウント: 1</p>

<!-- 新しい要素 -->
<p>カウント: 2</p>
レンダーフェーズ

reconcileChildren 関数が実行され、同じ p タグ同士であるため、sameTypetrue となります。
後のコミットフェーズでの処理のために pFiberalternateeffectTag を更新します。

if (sameType) {
  // update the node
  newFiber = {
    // ...
    alternate: oldFiber,
    effectTag: "UPDATE",
  };
}
コミットフェーズ

commitRoot 関数が実行され、peffectTag"UPDATE" なので updateDom 関数が実行されます。

if (fiber.effectTag === "UPDATE" && fiber.dom != null) {
  updateDom(fiber.dom, fiber.alternate.props, fiber.props);
  // テキストノードの内容が"カウント: 2"に更新される
}

これにより、変更内容が DOM に反映されてユーザーにはカウンターの値が 2 と表示されます。

5. hooks

React が提供する API

  • 状態を管理
    • useState, useReducer
  • 作用を管理
    • useEffect, useLayoutEffect
  • メモ化
    • useMemo, useCallback

参考
React

https://speakerdeck.com/recruitengineers/react-2023?slide=108

これらの中から useState の実装を見てみる。

useState

状態を扱うためのフック

特徴

  • 関数コンポーネントで状態を維持することが可能になる
    • 各コンポーネントの Fiber に配列として保存される
  • 初期値を引数に取り、現在の状態と状態更新関数を返す
  • setState を呼ぶと、コンポーネントの再レンダリングを発火
    • 状態更新はすぐには適用されず、次のレンダリングサイクルで処理される
  • 複数の useState を使って、複数の状態を管理できる
    • 呼び出し順序に基づいてフックを識別(インデックスを使用)

例えば、useState の実装はざっくり以下のような感じである。

function useState(initial) {
  // 前回のレンダリング時のフックを取得する。 wipFiber.alternate.hooks に前回のフックが保存されている
  const oldHook =
    wipFiber.alternate &&
    wipFiber.alternate.hooks &&
    wipFiber.alternate.hooks[hookIndex];

  // フックのオブジェクトを生成。前回のフックが存在すれば前回の状態、なければ初期値を使用。queue は状態更新関数を保存する配列
  const hook = { state: oldHook ? oldHook.state : initial, queue: [] };

  // 前回のレンダリングから適用されていない状態更新関数を適用することで常に最新の state が返却される
  const actions = oldHook ? oldHook.queue : [];
  actions.forEach((action) => {
    hook.state = action(hook.state);
  });

  // 状態を更新するための関数
  const setState = (action) => {
    // 更新アクションをキューに追加。これで次回のレンダリング時に oldHook.queue から取得可能
    hook.queue.push(action);

    // 新しい Fiber のルートを作成
    wipRoot = {
      dom: currentRoot.dom,
      props: currentRoot.props,
      alternate: currentRoot, // 現在のツリーを前回のツリーとして参照
    };

    // nextUnitOfWork が次に処理すべき Fiber を格納する変数
    // 新しいレンダリングフェーズを開始するために新しい wipRoot を次の作業単位として設定。
    // この処理があるから、setState の呼び出しで再レンダリングが発火する
    nextUnitOfWork = wipRoot;

    deletions = [];
  };

  // 現在の Fiber にこのフックの情報を保存。複数の state を管理するために Fiber には配列でフックを保存
  wipFiber.hooks.push(hook);

  // 次のフックのためにインデックスを増やす。
  hookIndex++;

  return [hook.state, setState];
}

簡易的な useEffect を実装してみる

ここまで学習した内容をもとに独自の useEffect フックを実装してみます。
実際の処理を追うところまでできなかったので、こんな感じかな?という部分が多くあります 🙇‍♂️

useEffect とは?

  • 関数コンポーネントで「副作用」を扱うためのフック
    • 副作用: レンダリング以外の処理(データ取得、購読、DOM の直接操作など)
  • 依存配列とエフェクト関数、クリーンアップ関数を持つ

useEffect の基本的な使い方は以下の通り。

useEffect(() => {
  document.title = `クリック数: ${count}`;

  // クリーンアップ関数(任意)
  return () => {
    console.log("コンポーネントがアンマウントされるか再レンダリングされます");
  };
}, [count]); // 依存配列

useEffect の実装において考えること

useEffect の実装において考えることは以下の通りかなと思う。

  • フックの情報管理
    • フックを Fiber に保存して再レンダリング間で状態を維持
    • 依存配列、エフェクト関数、クリーンアップ関数、を保持
  • 依存配列の比較
    • 依存配列に変更があった場合のみエフェクトを実行
    • 初回レンダリング時は無条件で実行
  • エフェクトの実行タイミングの管理
    • DOM 更新(コミットフェーズ)完了後にエフェクトを実行
      • 副作用なので、DOM が更新された後に実行する必要がある
  • 再レンダリング時とアンマウント時にクリーンアップ関数を実行

一つずつ実装していく。

フックの情報管理

フックを Fiber に保存して再レンダリング間で状態を維持できるようにします。
フックの情報は以下のような内容を持つオブジェクトとして管理します。

  • 依存配列、エフェクト関数、クリーンアップ関数を保持

また、hook に保存する cleanup 関数は、前回のレンダリングで実行されたエフェクトのクリーンアップ関数を保持するために使用します。そのため、初回レンダリング時は必ず undefined になります。

// 副作用(エフェクト)をコンポーネントのレンダリング後(コミットフェーズ後)に実行するための準備をする関数
function useEffect(effect, deps) {
  // 現在の Fiber から前回のフックを取得
  const oldHook =
    wipFiber.alternate &&
    wipFiber.alternate.hooks &&
    wipFiber.alternate.hooks[hookIndex];

  // 保存したいフック情報を作成
  const hook = {
    effect, // エフェクト関数
    deps, // 依存配列
    cleanup: oldHook ? oldHook.cleanup : undefined, // クリーンアップ関数。前回の effect で返されたクリーンアップ関数を実行する必要がある
    fiber: wipFiber, // Fiberへの参照
  };

  // Fiber にフック情報を保存
  wipFiber.hooks.push(hook);
  hookIndex++;

  // エフェクトの実行自体は後のコミット完了後に行うため、ここではフックを保存するだけ
  effectHooks.push(hook);
}

依存配列の比較

依存配列の比較を行う関数の実装を以下のように行う。

function areHookDepsEqual(prevDeps, nextDeps) {
  if (!prevDeps || !nextDeps) return false;
  if (prevDeps.length !== nextDeps.length) return false;

  return prevDeps.every((dep, i) => Object.is(dep, nextDeps[i]));
}

上記のメソッドを用いて、useEffect 内で依存配列の比較を行う。
そして、依存配列が変更された場合のみエフェクトを保存するようにコードを修正する。

function useEffect(effect, deps) {
  const oldHook =
    wipFiber &&
    wipFiber.alternate &&
    wipFiber.alternate.hooks &&
    wipFiber.alternate.hooks[hookIndex];

  const hook = {
    effect,
    deps,
    cleanup: oldHook ? oldHook.cleanup : undefined,
    fiber: wipFiber, // fiberへの参照を追加
  };

  // 依存配列が変更されたか、または初回実行時はエフェクトを実行キューに追加
  const hasChangedDeps = oldHook
    ? !areHookDepsEqual(oldHook.deps, hook.deps)
    : true;

  if (hasChangedDeps) {
    // エフェクトの実行自体は後のコミット完了後に行うため、ここではフックを保存するだけ。`runEffects` 関数で実行される
    effectHooks.push(hook);
  }

  wipFiber.hooks.push(hook);
  hookIndex++;
}

実行タイミングの管理

また、以下のようなエフェクトを実行するための関数を用意する。

function runEffects() {
  // effectHooks をコンポーネント単位でグループ化して処理
  const componentEffects = {};

  // コンポーネントごとにエフェクトをグループ化
  effectHooks.forEach((hook) => {
    // コンポーネントIDをキーとして使用
    const componentId = hook.fiber.props.key
      ? hook.fiber.props.key
      : hook.fiber.type.name || "undefined";
    if (!componentEffects[componentId]) {
      componentEffects[componentId] = [];
    }
    componentEffects[componentId].push(hook);
  });

  // コンポーネントごとにエフェクトを実行
  Object.values(componentEffects).forEach((hooks) => {
    // 各コンポーネント内で前のエフェクトのクリーンアップを実行してから、新しいエフェクトを実行
    hooks.forEach((hook) => {
      if (hook.cleanup) {
        hook.cleanup();
      }
    });

    // 同じコンポーネント内で新しいエフェクトを実行
    hooks.forEach((hook) => {
      hook.cleanup = hook.effect();
    });
  });

  // エフェクトキューをリセット
  effectHooks = [];
}

このエフェクトの実行関数は、DOM 更新後に呼び出す必要があります。DOM が更新されてから副作用を実行しないと、古い状態に対して操作してしまうことになり、意図した動作にならない可能性があるためです。
そのため、DOM を直接操作するコミットフェーズで呼び出される commitRoot 関数内でエフェクトを実行するようにします。

// コミットフェーズで呼び出す関数
function commitRoot() {
  // DOM への反映処理
  deletions.forEach(commitWork);
  commitWork(wipRoot.child);

  // DOM更新後にエフェクトを実行
  runEffects();

  // 次のレンダリングサイクルの準備
  currentRoot = wipRoot;
  wipRoot = null;
}

エフェクトの実行と再レンダリング時のクリーンアップ処理

各コンポーネント内で、前のエフェクト実行時に保存したクリーンアップ関数を実行した後に、新しいエフェクトを実行します。
hook.cleanup = hook.effect(); この部分で、エフェクト関数が実行され、その戻り値がクリーンアップ関数として保存されます。
そのため、普段 useEffect を使うときに、エフェクト関数の戻り値としてクリーンアップ関数を返す必要があります。

function runEffects() {
  // effectHooks をコンポーネント単位でグループ化して処理
  const componentEffects = {};

  // コンポーネントごとにエフェクトをグループ化・コンポーネントIDをキーとして使用
  effectHooks.forEach((hook) => {
    const componentId = hook.fiber.props.key
      ? hook.fiber.props.key
      : hook.fiber.type.name || "undefined";
    if (!componentEffects[componentId]) {
      componentEffects[componentId] = [];
    }
    componentEffects[componentId].push(hook);
  });

  // 各コンポーネント内で前のエフェクトのクリーンアップを実行してから、新しいエフェクトを実行
  Object.values(componentEffects).forEach((hooks) => {
    hooks.forEach((hook) => {
      if (hook.cleanup) {
        hook.cleanup();
      }
    });

    hooks.forEach((hook) => {
      hook.cleanup = hook.effect();
    });
  });

  effectHooks = [];
}

アンマウント時のクリーンアップ関数の実行

また、コンポーネントがアンマウントされる際にクリーンアップ関数を実行する必要があります。
そのため、コンポーネントがアンマウントされる際に呼び出す commitDeletion 関数内で、DOM の削除前にクリーンアップ関数を実行するようにします。

// コンポーネントがアンマウントされる際に呼び出す関数
function commitDeletion(fiber, domParent) {
  // コンポーネント削除前にクリーンアップ関数を実行
  if (fiber.hooks) {
    fiber.hooks.forEach((hook) => {
      if (hook.cleanup) {
        hook.cleanup();
      }
    });
  }

  if (fiber.dom) {
    domParent.removeChild(fiber.dom);
  } else {
    commitDeletion(fiber.child, domParent);
  }
}

まとめ

React の内部実装の以下の基礎知識を学ぶことができました。

  • 仮想 DOM の概念
  • 作業単位の分割とスケジューリング (Fiber)
  • レンダーとコミットの分離
  • Reconciliation の仕組み
  • hooks の実装方法

これによって今後の React のアップデートの意図や仕様の理解がスムーズになる予感がしています。

参考資料

share this article with X
profile

ryounasso