はじめに:メンタルモデルのギャップと本記事の目的

Reactで一定期間開発を続けていると、state更新の挙動が「直感的に理解できている」つもりでいるのに、特定の条件下でだけ予期しない再レンダリングや古い値の参照が発生する、という経験をしたことがあるだろう。その原因の多くは、Reactを「setStateを呼べば即座にDOMが更新される同期処理」として捉えているメンタルモデルのズレにある。

実際、Reactはstate更新の呼び出し時にDOMを直接変更せず、計算を未来にスケジュールするトリガーとして動作する。更新はTrigger、Render、Commitの3フェーズで処理される(DEVの解説記事)。この非同期スケジューリングの性質を理解していないと、バッチ処理のタイミングやクロージャーによる古い値の参照といった問題の根本原因を特定できない。

本記事は、従来の「Virtual DOMの差分比較」という表面的な説明にとどまらず、Fiberアーキテクチャに基づく中断可能なレンダリングやスナップショットモデルといった内部実装に焦点を当てる。対象とするのは、Reactの動作に課題を感じ、パフォーマンス最適化やバグ再発防止のために深い理解を求める中級〜上級のフロントエンドエンジニアである。

既存の解説記事と本記事の違いは、実装手順の提示ではなく「設計判断の根拠」の提示にある。useMemoをどこに置くべきか、フックの依存配列をどう設計すべきかといった判断の背後にある技術的制約を、内部実装の文脈から説明することで、状況に応じた最適な実装を自ら選択できる判断材料を提供する。

Reactの更新フロー:TriggerからCommitまでの3フェーズ

Reactの更新処理は、state更新の呼び出しからDOMの反映まで3つのフェーズで構成される。それぞれが異なる性質を持つため、混同すると設計判断を誤る原因になる。

Trigger:計算のスケジュール

state更新関数は呼び出し時点ではDOMを一切変更しない。このフェーズで行われるのは、変更内容をキューに積み、レンダリングの計算を未来にスケジュールすることだけである(DEVの解説記事)。つまり、state更新関数の直後にstateの値を読み取っても、変更前の値が返ってくる。この「トリガーであるが処理ではない」という性質が、非同期処理での古い値参照問題の根本原因となる。

Render:純粋なJS計算と差分生成

Renderフェーズでは、コンポーネント関数が再実行され、JSXがReact.createElement関数呼び出しに変換されてUIの構造を表すプレーンなJavaScriptオブジェクト(Virtual DOMノード)が生成される(DEVの解説記事)。生成された新しいツリーと以前のツリーを比較するReconciliation(差分計算)により、DOMに適用すべき最小限の変更パッチが算出される。このフェーズは純粋なJS計算であり、DOM操作は一切行われない。

Commit:同期DOM更新

Commitフェーズは3フェーズの中で唯一の同期処理であり、中断不可である。Reactはnative browser APIsを用いて、差分計算の結果に基づき変更が必要な特定のDOMノードのみを更新する(DEVの解説記事)。useEffectの副作用実行もこのフェーズに属する。中断不可であるため、このフェーズが重い処理を含むとメインスレッドがブロックされる。

flowchart TD
    A["state更新 呼び出し"] --> B["Trigger フェーズ\n変更のスケジュール"]
    B --> C["Render フェーズ\n純粋なJS計算 + 差分生成"]
    C --> D{"差分が\n存在するか"}
    D -->|Yes| E["Commit フェーズ\n同期DOM更新(中断不可)"]
    D -->|No| F["処理終了"]
    E --> G["useEffect 実行"]

この3フェーズの性質を踏まえると、設計上の判断基準が明確になる。Renderフェーズは中断可能なため重い計算を許容される一方、Commitフェーズは中断不可のためDOM操作の最小化が求められる。この違いを意識せずに「レンダリングが重い」と一概に判断すると、最適化の方向性を誤る。

Fiberアーキテクチャの核心:中断可能性と優先度

React 15以前のレンダリングは、コンポーネントツリー全体を再帰的に走査して差分を計算する同期的なプロセスだった。ツリーが深い、またはノード数が膨大な場合、この再帰処理がメインスレッドを長時間ブロックし、ユーザー入力の応答が途絶える。Fiberアーキテクチャは、この問題に対する根本的な解決策として設計された。

連結リストによる単位作業の分割

Fiberは、コンポーネントツリーを再帰的に処理するのではなく、各ノードを連結リストのように扱って単位作業に分割する。各Fiberノードは、処理済みの子ノードへの参照(child)、兄弟ノードへの参照(sibling)、親ノードへの参照(return)を保持しており、処理を中断した時点から再開できる状態を維持する。これにより、Reactは1回のイベントループイテレーションで一部のノードのみを処理し、制御をブラウザに返すことができる。

優先度ベースのスケジューリング

Fiberの各タスクには優先度が割り当てられる。ユーザー入力(keydown、clickなど)から生じるレンダリングは高優先度として扱われ、背景でのデータ取得後の再レンダリングは低優先度として扱われる。高優先度のタスクが発生した際、進行中の低優先度レンダリングは中断され、高優先度タスクが完了した後に再開される。この仕組みにより、複雑なUIでもメインスレッドが長時間ブロックされることなく、ユーザー入力のレスポンス性が保たれる。

Renderフェーズの純粋性という制約の背景

Fiberの中断可能性は、Renderフェーズが「純粋関数的」であるべきという制約の根拠でもある。レンダリングが中断された後に再開される場合、コンポーネント関数が再実行される。副作用(DOM操作、外部API呼び出し、stateの直接変更など)が存在すると、中断と再開の間に状態が変化し、不整合が生じる。例えば、useEffect外でfetchを呼ぶと、中断時にリクエストが送られ、再開時に再度リクエストが送られる二重実行が起きる。この制約は「規約」ではなく、Fiberの中断・再開メカニズムが正しく動作するための技術的必然である。

フックの内部実装:memoizedStateとポインタの罠

フックの保存構造:シングルクイリスト

フックはコンポーネントのFiberノード上のmemoizedStateプロパティに、シングルクイリストとして保存される。各ノードはmemoizedState(状態値)とnext(次のフックへのポインタ)からなる。つまり、useStateを3回呼んだコンポーネントは、Fiberノードに3つのノードが連結されたリストを保持している。

// 擬似コード:Fiberノード内のフックリスト構造
// fiber.memoizedState の実際の内部構造
{
  memoizedState: 0,       // 1番目の useState の値
  next: {
    memoizedState: [],    // 2番目の useState の値
    next: {
      memoizedState: { /* effect 情報 */ },  // 3番目の useEffect
      next: null
    }
  }
}

この構造は、フックが「名前」ではなく「位置」で識別されることを意味する。const [count, setCount] = useState(0)とconst [name, setName] = useState('')は、内部ではどちらも「リストの先頭からn番目のノード」に過ぎない。

ポインタの移動と呼び出し順序の必然

レンダリングが開始されると、Fiberノード内の内部ポインタがリストの先頭へリセットされる。コンポーネント関数が実行され、フックが順に呼び出されるたびに、ポインタは現在のノードから状態を読み取り、nextへ移動する。この動作は、呼び出し順序がレンダリング間で完全に一致することを前提としている。

sequenceDiagram
    participant R as レンダラー
    participant F as Fiberノード
    participant S as シングルクイリスト

    R->>F: レンダリング開始
    F->>S: 内部ポインタを先頭ノードへ
    R->>S: 1番目のフック useState
    S-->>R: 状態値を返す
    S->>S: ポインタを次ノードへ
    R->>S: 2番目のフック useEffect
    S-->>R: 状態値を返す
    S->>S: ポインタを次ノードへ
    R->>R: レンダリング完了

ここで重要なのは、2回目のレンダリングで1番目にuseEffectを呼んだ場合、ポインタの先頭ノードに保存されているのはuseStateの値(数値)であり、effectの情報ではない。型が一致せず、データが破損する。これが「フックの呼び出し順序は安定させるべき」というルールの技術的根拠である。

順序違反が引き起こすデータ破損

条件分岐内でフックを呼ぶ典型的なバグパターンを示す。

// 誤:条件分岐内でフックを呼び出し
function Component({ isAdmin }) {
  const [name, setName] = useState('');

  if (isAdmin) {
    // 初回レンダリングで isAdmin=true だった場合、
    // この useState はリストの2番目ノードに保存される
    const [role, setRole] = useState('admin');
  }

  // isAdmin=false のレンダリングでは上記フックが呼ばれない
  // → ポインタが2番目ノードを読み飛ばす
  // → 以降のフックがすべて1つずれて読み取り、データ破損
}

Reactはこの不整合を検出すると、Rendered fewer hooks than expectedエラーを投げて実行を停止する。これは防御的エラーハンドリングであり、破損した状態でUIを描画するのを防ぐための安全装置である。開発者が「なぜこのエラーが出るのか」をポインタのずれとして理解していれば、条件分岐をフック呼び出しの外側に移動するという修正が即座に導かれる。

スナップショットモデル:props/stateの定数性とクロージャー

各レンダリングにおける定数性

Reactの各レンダリングにおいて、propsとstateは定数として扱われる。setStateはローカル変数を変更するのではなく、次回レンダリング時に適用される値をスケジュールするものである。この設計は、宣言的UIの根幹を成す。コンポーネント関数は「現在の状態の関数」として純粋に動作し、同じprops/stateが与えられれば同じUIを返す。

function Counter() {
  const [count, setCount] = useState(0);

  // このレンダリング中は count は常に 0 である
  // setCount(1) を呼んでも、この関数スコープ内の count は 0 のまま
  console.log(count); // → 0

  return (
    <button onClick={() => setCount(1)}>
      {count} {/* → 0 */}
    </button>
  );
}
// setCount(1) が処理され、再レンダリングが発生して初めて count = 1 になる

この定数性は、Reactがレンダリングを中断・再開できる(Fiberの性質)ことと密接に関係している。もしレンダリング中にstateが変化すると、中断前の計算結果と再開後の計算結果が不整合になる可能性がある。定数性という制約が、Fiberの中断可能性を支えているのである。

非同期処理でのクロージャー問題

非同期コールバック内では、関数が作成された時点のスナップショット(クロージャー)が保持される。つまり、setTimeoutやfetchのコールバック内で参照するstateは、その関数が生成されたレンダリングの値であり、最新の値ではない。

function Timer() {
  const [count, setCount] = useState(0);

  const handleClick = () => {
    setCount(c => c + 1); // 関数形式なら常に最新値から計算
    setTimeout(() => {
      console.log(count); // ← 常に 0(クロージャーに閉じ込められた値)
      setCount(count + 1); // ← バグ: 0 + 1 = 1 になる可能性
    }, 1000);
  };

  return <button onClick={handleClick}>{count}</button>;
}

この問題の根源は、handleClickが生成されたレンダリングのcount(0)をクロージャーとして保持していることである。stateは定数であり、再レンダリングされても既存の関数オブジェクト内のクロージャーは更新されない。

実務パターン:useRefとuseEffectの使い分け

この制約に対処する代表的なアプローチには、それぞれトレードオフがある。

項目state(スナップショット)ref(ミュータブル参照)
変更の反映タイミング次回レンダリング時即時(レンダリングをトリガーしない)
非同期コールバック内での参照関数生成時の値(クロージャー)常に最新値
再レンダリング変更時にトリガートリガーしない
適切な使用場面UIの表示状態の管理最新値の参照、DOM要素、外部APIのインスタンス

useRefはレンダリングをトリガーしないため、「最新値を参照したいがUI更新は不要」という場面に適する。一方、useEffectの依存配列にstateを含めることで、state変化時にeffectが再実行され、クロージャーが更新される。ただしこれは「stateが変わるたびに副作用が走る」ことを意味し、不要な副作用が発生する可能性がある。どちらを選ぶかは、参照したい値がUIに反映されるべきかどうかに依存する。

設計判断の具体例:パフォーマンスとバグ回避のための選択

useMemo/useCallbackの判断基準

useMemoやuseCallbackの使用は、Renderフェーズの計算コストとCommitフェーズのDOM更新コストのトレードオフに基づいて判断すべきである。Renderフェーズは純粋なJS計算であり、中断可能だが再実行される可能性がある。Commitフェーズは同期処理であり中断不可で、DOMに直接影響を与える。つまり、Renderフェーズで軽くしてCommitフェーズのDOM操作を減らすのか、Renderフェーズの計算コストを許容してCommitフェーズを軽くするのか、という判断である。

具体的には、子コンポーネントがReact.memoでラップされており、propsの参照等価性が再レンダリングを防止できる場合、useCallbackで関数をキャッシュする意味がある。一方、子コンポーネントがReact.memoなしで、親の再レンダリング時に必ず再レンダリングされる場合、useCallbackは参照等価性を保つだけで、実際のDOM更新は防げない。この場合のuseCallbackは、メモリ上の関数オブジェクトを再利用する程度の効果しかなく、むしろ可読性を損なう可能性がある。

Stale Closureの具体例と回避策

依存配列の設定ミスは、クロージャー越しに古いstateを参照するバグ(Stale Closure)の典型的な原因である。

function SearchBox() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);

  // 依存配列に query が含まれていない
  useEffect(() => {
    fetch(`/api/search?q=${query}`)
      .then(res => res.json())
      .then(setResults);
  }, []); // ← バグ: query の変化で再実行されない

  const handleChange = (e) => setQuery(e.target.value);
  // query が更新されて再レンダリングされるが、
  // useEffect は再実行されない → results は古いまま
  // 仮に再実行されたとしても、クロージャー内の query は
  // effect が生成された時点の値(初期値 '')を保持する
}

回避策として、setStateの関数形式(setCount(c => c + 1))は、Reactが最新のstate値を引数として渡すため、クロージャーの古い値に依存しない。これはstateの更新が「現在の値の関数」として表現できる場合に有効なパターンである。ただし、stateの値を参照して別の処理(API呼び出しなど)を行う場合は、関数形式では解決できない。useRefで最新値を保持するか、useEffectの依存配列を正しく設定する必要がある。

仮想スクロールにおける制御の委譲判断

大規模なリスト描画でFiberの中断機能を活用する場合、Reactに制御を委譲すべきか、自前で最適化すべきかの判断基準が存在する。ReactのFiberはレンダリングを中断可能にするが、それは「一度にレンダリングする要素数」を減らすことには直接つながらない。10,000個のli要素をすべてコンポーネントとして描画する場合、Renderフェーズの計算自体が重くなり、中断されても総計算量は変わらない。

この場合、仮想スクロール(表示領域外の要素をDOMから除外する)は、CommitフェーズでDOM操作する要素数を物理的に減らすアプローチであり、Fiberの中断とは別次元の最適化である。判断基準は次のようになる:

  • Reactに委譲する:レンダリング自体が軽い(要素数が数百以下)が、state更新頻度が高く、中断によるレスポンス性が重要である場合。
  • 自前で最適化する:描画対象の要素数が非常に多く(数千以上)、Renderフェーズの計算コスト自体がボトルネックである場合。仮想スクロールやウィンドウリングでDOMノード数を削減する。

両者は排他的ではなく、仮想スクロールでDOMノード数を抑えつつ、表示領域内の要素のレンダリングはFiberの中断に委譲する、という組み合わせが最も自然である。重要なのは、どのフェーズ(Renderの計算コストかCommitのDOM操作コストか)がボトルネックかを特定し、それに合った手段を選択することである。

デバッグの実践:内部状態を可視化するアプローチ

Reactのバグを「なぜ起きたか」のレベルで理解するためには、内部状態を可視化する手段を積極的に活用する必要がある。最も実用的なツールはReact DevToolsのProfilerタブである。Profilerではコンポーネントツリーの各ノードがレンダリングされた回数と所要時間を確認でき、予期しない再レンダリングの発生箇所を特定できる。

ここで重要なのは、単に「再レンダリングが多い」こと自体を問題視しないことである。前述のスナップショットモデルに立ち返れば、コンポーネントが再レンダリングされることは正常な動作であり、問題は「その再レンダリングがCommitフェーズで実際のDOM変更を伴うかどうか」である。Profilerで再レンダリングを検出したら、次はpropsの伝播経路をFiberノードの構造として追う。親コンポーネントのstate更新が、どの子コンポーネントのpropsに変更として伝わり、それがDOMの再描画に繋がったのかを具体的に特定する。

フックの呼び出し順序違反のデバッグ

「Hooks can only be called inside the body of a component」や「Rendered more/fewer hooks than during the previous render」などのエラーが出力された場合、根本原因はFiberノード上のmemoizedStateリストにおけるポインタのずれである。条件分岐内でフックを呼び出した結果、レンダリング間でフックの数が変化し、2回目以降のレンダリングでポインタが意図しない位置のstateを参照してしまう。この理解があることで、「どこで条件分岐を追加したか」を即座に特定でき、修正が速くなる。

開発環境と本番環境の差異

開発環境ではReactが厳格なチェック(フックの順序検証、純粋性の検証など)を実行するため、本番ビルドでは顕在化しない問題が早期に検出される。逆に、開発環境ではoverheadがあるため、パフォーマンス計測の本番ビルドとの数値を直接比較しないこと。デバッグ時は「開発環境の厳格チェックで検出された警告」を本番での潜在バグとして扱う姿勢が重要である。

Community & Ecosystem: Reactの進化を支える背景

Reactの技術的な進化は、単にMeta社内の開発チームのみによって推進されているわけではない。オープンソースコミュニティの貢献が、Fiberアーキテクチャの改善や新機能の設計に直接的に反映されてきた。

このコミュニティを支える基盤の一つとして、MLH(Major League Hacking)のようなハッカソンプラットフォームが挙げられる。MLHは500万人以上の開発者を持つ最大のハッカソンコミュニティであり、年間1000以上のイベントを100カ国で開催している(MLH公式)。また、MLHはDEV(dev.to)を買収し、GoogleやGitHubなどの企業がコミュニティを支える開発者エコシステムを形成している。

中でもMLHフェローシップは、12週間の完全リモートインターンシップ代替プログラムとして、参加者に給付金を提供しながらトップ企業のエンジニアと共同でオープンソースプロジェクトに取り組ませる仕組みである。Reactのような大規模OSSプロジェクトのコントリビューションは、この種のプログラムを通じて若手エンジニアが実装原理を深く理解する場ともなっている。本記事で解説したFiberノードの構造やスナップショットモデルの理解は、コントリビューション時に「なぜその実装になっているか」を判断する基礎知識として機能する。

まとめ:内部原理に基づく堅牢なReact設計へ

本記事では、Reactの内部実装をFiberアーキテクチャとスナップショットモデルの二軸から整理し、それに基づく設計判断とデバッグ指針を提示した。要点を振り返る。

第一に、setStateは同期処理ではなく、Trigger→Render→Commitの3フェーズで処理される非同期スケジューリングである。Renderフェーズは純粋なJS計算であり中断可能だが、Commitフェーズは同期で中断不可である。この非対称性が、副作用の配置場所(Render外、Commit内)を決定している。

第二に、フックはFiberノード上のリストに保存され、呼び出し順序の安定性がメモリ構造上の必須条件である。これは「規約」ではなく、ポインタのずれによるデータ破損を防ぐ技術的制約である。

第三に、各レンダリングのpropsとstateは定数(スナップショット)であり、非同期処理で最新値が必要な場合はuseRefなどのミュータブル参照を明示的に使う設計判断が必要である。

これらの内部原理は、Reactのバージョンやレンダリング戦略(Client Components / Server Components)がどのように進化しても、状態管理とレンダリングの核心部分として維持される。表面的なベストプラクティスを暗記するのではなく、「なぜその制約が存在するのか」を内部構造から導出できるエンジニアが、状況に応じた最適解を判断できる。次のプロジェクトでパフォーマンス問題や予期しないバグに遭遇したとき、本記事の視点をデバッグの出発点として活用してほしい。

関連トピック

  • Reactのレンダリング最適化:React.memo、useMemo、useCallbackの適用判断と効果を測る方法
  • JavaScriptのイベントループとマイクロタスク:非同期処理のスケジューリングがUI応答性に与える影響
  • TypeScriptによる状態管理の型安全設計:discriminated unionとリダクションパターン
  • フロントエンドのパフォーマンス計測:Web Vitals(LCP、INP、CLS)とレンダリングボトルネックの特定

関連記事

参考

本記事は海外の技術トレンド「How React Actually Works Under the Hood (And Why Your Mental Model Might Be Wrong)」(DEV)で話題のテーマをもとに、両儀システムソリューションズが独自に解説したものです。