Read OSS

フックとディスパッチャー — React のステートマシン

上級

前提知識

  • 第 1 回: アーキテクチャ概要(フォークシステムと SharedInternals ブリッジ)
  • 第 2 回: Fiber データ構造(fiber.memoizedState フィールド)
  • 第 3 回: ワークループ(beginWork が関数コンポーネントを呼び出す仕組み)
  • React フックの利用経験(useState、useEffect、useRef、useMemo)

フックとディスパッチャー — React のステートマシン

フックは、関数コンポーネントを React の一等市民へと押し上げた API です。と同時に、システム全体のなかでも特に設計が興味深い部分でもあります。呼び出し順序によって同一性を保つステートマシンであり、レンダラーに依存しないフックのスタブを実現するためにディスパッチャーの間接呼び出しでパッケージ間の橋渡しをしています。

この記事では、react パッケージの useState 呼び出しがディスパッチャーを経由してリコンサイラーの実装に到達するまでの経路を追いかけます。フックの状態が fiber.memoizedState のリンクリストとしてどう格納されるか、マウント時とアップデート時のディスパッチャー切り替えの仕組み、そしてエフェクトシステムのタグビットの意味についても読み解いていきましょう。

ディスパッチャーパターン — レンダラーに依存しないフック

コンポーネントの中で useState を呼ぶとき、実際には react パッケージがエクスポートしている関数を呼んでいます。ところが react パッケージはどのレンダラーにも依存していません。Fiber も、ワークループも、DOM も知らないのです。それでも動くのはなぜでしょうか?

答えは ReactHooks.js にあります。

function resolveDispatcher() {
  const dispatcher = ReactSharedInternals.H;
  // Will result in a null access error if accessed outside render phase.
  return ((dispatcher: any): Dispatcher);
}

export function useState(initialState) {
  const dispatcher = resolveDispatcher();
  return dispatcher.useState(initialState);
}

すべてのフックスタブは同じパターンに従っています。ReactSharedInternals.H から現在のディスパッチャーを取得し、そこに処理を委譲するだけです。ディスパッチャーは Dispatcher インターフェースを実装したプレーンなオブジェクトで、各フックに対応するメソッドを持っています。

sequenceDiagram
    participant C as Component Code
    participant R as react/ReactHooks.js
    participant SI as ReactSharedInternals.H
    participant D as Reconciler Dispatcher

    C->>R: useState(0)
    R->>SI: resolveDispatcher()
    SI-->>R: current dispatcher object
    R->>D: dispatcher.useState(0)
    D-->>R: [state, setState]
    R-->>C: [state, setState]

プロパティ名 H が一文字なのは意図的です。ホットパスで使われるため、ミニファイ後も確実に動作するよう短くしてあります。SharedInternals のプロパティ一覧は ReactSharedInternalsClient.js で定義されています。

プロパティ 用途
H フックディスパッチャー
A 非同期ディスパッチャー(Cache 用)
T 現在のトランジション
S startTransition の完了コールバック
G ジェスチャートランジションのコールバック

Hnull(レンダーフェーズ外)のとき、resolveDispatcher() はあえて例外を投げません。null を返してその後のメソッド呼び出しで null アクセスエラーを発生させます。こうすることで V8 がこの関数をインライン展開しやすくなります。

Tips: ここで第 1 回で解説したフォークシステムが重要になります。react パッケージをビルドするとき、shared/ReactSharedInternalsreact/src/ReactSharedInternalsClient.js(直接の定義ファイル)にフォークされます。一方、react-dom など他のパッケージでは shared/ReactSharedInternals.js に解決され、そこから React.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE を読み取ります。

フックのリンクリスト — fiber.memoizedState

リコンサイラーの内部では、レンダー中にフックが呼ばれるたびに Hook ノードが生成または走査されます。これらは fiber.memoizedState を起点とした単方向リンクリストです。新しいノードを作るのが mountWorkInProgressHook 関数です。

function mountWorkInProgressHook(): Hook {
  const hook: Hook = {
    memoizedState: null,  // The hook's own state
    baseState: null,      // Base state for update rebasing
    baseQueue: null,      // Updates that weren't processed
    queue: null,          // Circular linked list of pending updates
    next: null,           // Link to next hook
  };

  if (workInProgressHook === null) {
    // First hook in the list
    currentlyRenderingFiber.memoizedState = workInProgressHook = hook;
  } else {
    // Append to the end of the list
    workInProgressHook = workInProgressHook.next = hook;
  }
  return workInProgressHook;
}

フックを毎回同じ順序で呼ばなければならない理由はここにあります。アップデート時、React はリストを順番に走査し、各フック呼び出しを対応するノードに紐づけます。条件分岐でフックをスキップすると、それ以降のフックはすべて一つズレたノードの状態を読んでしまいます。

graph LR
    FM["fiber.memoizedState"] --> H1["Hook 1<br/>(useState)"]
    H1 -->|next| H2["Hook 2<br/>(useEffect)"]
    H2 -->|next| H3["Hook 3<br/>(useMemo)"]
    H3 -->|next| N["null"]

    style H1 fill:#e1f5fe
    style H2 fill:#f3e5f5
    style H3 fill:#e8f5e9

memoizedState に何を格納するかはフックの種類によって異なります。

フック memoizedState の内容
useState 現在の状態値
useReducer 現在の状態値
useRef {current: value}
useMemo [computedValue, deps]
useCallback [callback, deps]
useEffect Effect オブジェクト
useContext Hook ノードなし(コンテキストスタックを直接読む)

マウント時とアップデート時のディスパッチャー

リコンサイラーは 3898 行目 で定義された 3 種類のディスパッチャーオブジェクトを持っています。

HooksDispatcherOnMount — 新しい Hook ノードを作成し、状態を初期化します。

const HooksDispatcherOnMount: Dispatcher = {
  useState: mountState,
  useEffect: mountEffect,
  useRef: mountRef,
  useMemo: mountMemo,
  // ...
};

HooksDispatcherOnUpdate — 既存の Hook ノードを走査し、キューに積まれたアップデートを処理します。

const HooksDispatcherOnUpdate: Dispatcher = {
  useState: updateState,
  useEffect: updateEffect,
  useRef: updateRef,
  useMemo: updateMemo,
  // ...
};

HooksDispatcherOnRerender — レンダー中に状態更新が発生した場合(レンダー関数の本体で setState を呼んだ場合)に使われます。

const HooksDispatcherOnRerender: Dispatcher = {
  useState: rerenderState,
  useReducer: rerenderReducer,
  // most hooks delegate to the update version
};

切り替えは renderWithHooks の中で行われます。この関数は beginWork が関数コンポーネントを処理するときに呼ばれます。

export function renderWithHooks(current, workInProgress, Component, props, ...) {
  currentlyRenderingFiber = workInProgress;
  workInProgress.memoizedState = null; // Reset for mount
  workInProgress.updateQueue = null;

  ReactSharedInternals.H =
    current === null || current.memoizedState === null
      ? HooksDispatcherOnMount
      : HooksDispatcherOnUpdate;

  // Now call the component function
  let children = Component(props, secondArg);
  // ...
}

current が null(初回レンダー)であればマウント用ディスパッチャーが、そうでなければアップデート用ディスパッチャーがセットされます。コンポーネント関数が返った後、renderWithHooksReactSharedInternals.H を null にリセットします。レンダーフェーズ外でフックを呼ぶと例外が発生するのはこのためです。

flowchart TD
    RWH["renderWithHooks(current, wip, Component)"]
    Check{current === null?}
    Mount["H = HooksDispatcherOnMount"]
    Update["H = HooksDispatcherOnUpdate"]
    Call["children = Component(props)"]
    Reset["H = null"]

    RWH --> Check
    Check -->|Yes| Mount
    Check -->|No| Update
    Mount --> Call
    Update --> Call
    Call --> Reset

useState と useReducer — アップデートキュー

useState は実際には組み込みのリデューサーを持つ useReducer の上に実装されています。mountState はアップデートキューを持つ Hook ノードを作成します。

function mountState(initialState) {
  const hook = mountStateImpl(initialState);
  const queue = hook.queue;
  const dispatch = dispatchSetState.bind(null, currentlyRenderingFiber, queue);
  queue.dispatch = dispatch;
  return [hook.memoizedState, dispatch];
}

dispatch 関数(つまり setState)を呼ぶと、dispatchSetState がアップデートオブジェクトを作成し、hook.queue循環リンクリストに追加します。アップデートにはレーン(優先度)も割り当てられ、Fiber は再レンダーのスケジュールに登録されます。

重要な最適化として、dispatchSetState状態の先行計算を行います。レンダーが進行中でなく、新しい状態が現在の状態と同じ場合(Object.is で比較)、アップデートは完全にスキップされ、再レンダーはスケジュールされません。同じ値で setState(prevState) を呼んでもコストがかからないのはこの仕組みのおかげです。

アップデートのレンダー時には、updateReducer がキューに積まれたアップデートを順番に再適用して処理します。並行レンダリングでは、アップデートのリベースが必要になることもあります。優先度の高いレンダーが低優先度のレンダーに割り込んだ場合、低優先度のアップデートは baseQueue に保持され、baseState の上で改めて再適用されます。

エフェクトシステム — Passive・Layout・Insertion エフェクト

エフェクトは状態フックとは異なる方法で格納されます。useEffect を呼ぶと、フックの memoizedStateEffect オブジェクトを指し、さらにそのエフェクトは fiber.updateQueue(関数コンポーネントでは循環リンクリスト)にも追加されます。

ReactHookEffectTags.js でタグビットが定義されています。

export const NoFlags   = 0b0000;
export const HasEffect = 0b0001;  // Effect needs to fire
export const Insertion = 0b0010;  // useInsertionEffect
export const Layout    = 0b0100;  // useLayoutEffect
export const Passive   = 0b1000;  // useEffect

HasEffect が核心となるフラグです。エフェクトの依存値が変化したときにセットされます。このフラグがなければ、エフェクトオブジェクト自体は Fiber に残り続けますが(クリーンアップの追跡に必要)、コミットフェーズは再実行しないと判断できます。

3 種類のエフェクトはコミットフェーズの異なるタイミングで実行されます(第 3 回で解説した通りです)。

flowchart LR
    subgraph "Commit Phase Timing"
        IE["useInsertionEffect<br/>tag: Insertion | HasEffect<br/>Fires in: Mutation phase<br/>Before DOM updates visible"]
        LE["useLayoutEffect<br/>tag: Layout | HasEffect<br/>Fires in: Layout phase<br/>After DOM mutation, before paint"]
        PE["useEffect<br/>tag: Passive | HasEffect<br/>Fires in: Passive phase<br/>Asynchronous, after paint"]
    end

    IE --> LE --> PE

mountEffect は Passive タグを持つエフェクトを生成し、Fiber に Passive フラグをセットします。このフラグは subtreeFlags のバブリングに反映され、パッシブエフェクトを持たないサブツリーをコミットフェーズがスキップできるようになります。

その他のフック — useRef・useMemo・useCallback・useContext

残りのフックは比較的シンプルな実装です。

useRefmountRef{current: initialValue} オブジェクトを作り、hook.memoizedState に格納します。アップデート時、updateRef は既存のオブジェクトをそのまま返します。ref は再作成されることがありません。

function mountRef(initialValue) {
  const hook = mountWorkInProgressHook();
  const ref = {current: initialValue};
  hook.memoizedState = ref;
  return ref;
}

useMemouseCallback[value, deps] のタプルを格納します。アップデート時、React は新旧の deps を Object.is で要素ごとに比較します。すべて一致すればメモ化された値を返し、一つでも変わっていれば関数を再実行(useMemo の場合)するか、新しいコールバックを保存(useCallback の場合)します。

useContext は独特で、Hook ノードをまったく作りません。readContext を通じてリコンサイラーのコンテキストスタックを直接読み取ります。理論上は useContext がフックの順序ルールに縛られない理由はここにありますが、React は DEV モードで一貫性のために追跡を行っています。

useTransition はレーンシステムと密接に連携している点が面白いフックです。startTransition(callback) を呼ぶと、React はコールバックを実行する前に更新優先度を TransitionLane に一時的に切り替えます。コールバック内での setState 呼び出しはすべてトランジションレーンに割り当てられ、タイムスライスを使ってレンダーされます。

Tips: 「フックのルール」は lint ツールが魔法のように強制しているわけではありません。これはリンクリストの構造上の必然です。毎回のレンダーで、各フック呼び出しはリストの同じ位置に対応していなければなりません。条件付きでフックを追加すると、Hook 3 が Hook 2 の状態を読んでしまい、原因究明が難しいバグを引き起こします。

次回予告

フックが react パッケージと react-reconciler パッケージをどう橋渡しするか、状態が Fiber にどう格納されるか、エフェクトシステムがコミットフェーズとどう連携するかを見てきました。次回はリコンサイラーからレンダラーへの境界を越えます。React をレンダラー非依存にするホスト設定コントラクトの仕組みを解説し、react-dom-bindings がブラウザ DOM 向けにそれをどう実装しているかを、イベント委譲システムや DOM ミューテーション操作も含めて追いかけていきましょう。