フックとディスパッチャー — 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 |
ジェスチャートランジションのコールバック |
H が null(レンダーフェーズ外)のとき、resolveDispatcher() はあえて例外を投げません。null を返してその後のメソッド呼び出しで null アクセスエラーを発生させます。こうすることで V8 がこの関数をインライン展開しやすくなります。
Tips: ここで第 1 回で解説したフォークシステムが重要になります。
reactパッケージをビルドするとき、shared/ReactSharedInternalsはreact/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(初回レンダー)であればマウント用ディスパッチャーが、そうでなければアップデート用ディスパッチャーがセットされます。コンポーネント関数が返った後、renderWithHooks は ReactSharedInternals.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 を呼ぶと、フックの memoizedState は Effect オブジェクトを指し、さらにそのエフェクトは 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
残りのフックは比較的シンプルな実装です。
useRef — mountRef は {current: initialValue} オブジェクトを作り、hook.memoizedState に格納します。アップデート時、updateRef は既存のオブジェクトをそのまま返します。ref は再作成されることがありません。
function mountRef(initialValue) {
const hook = mountWorkInProgressHook();
const ref = {current: initialValue};
hook.memoizedState = ref;
return ref;
}
useMemo と useCallback は [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 ミューテーション操作も含めて追いかけていきましょう。