本記事は、React の内部実装を理解するための学習ログです。
前回の mountState / updateState 編に続き、今回は dispatchSetState の中身を読んでいきます。
この記事で分かること
setCount(11)のように setState を呼んだとき、React の内部で何が起きるのか。
「state がすぐに変わらない」のはなぜか。その仕組みを、ソースコードを追いながら理解します。
useState の全体像
本題に入る前に、useState に関わる処理の全体像を俯瞰しておきましょう。
useState() が呼ばれたとき
コンポーネント内で useState() が呼ばれると、初回レンダーか再レンダーかで異なる関数に振り分けられます。この振り分けは Dispatcher(前々回の記事で解説)が担当しています。
const [count, setCount] = useState(10);
│
▼
renderWithHooks()
→ Dispatcher を切り替える
│
├─ 初回レンダー(mount)
│ ▼
│ ┌─────────────────────────────────────────────┐
│ │ mountState(initialState) │
│ │ → Hook ノードを生成し、Fiber に紐付け │
│ │ → dispatch = dispatchSetState.bind(...) │
│ │ → return [initialState, dispatch] │
│ └─────────────────────────────────────────────┘
│
└─ 再レンダー(update)
▼
┌─────────────────────────────────────────────┐
│ updateState(initialState) │
│ → initialState は無視される │
│ → queue に積まれた update を順番に適用 │
│ → 新しい state を計算 │
│ → return [newState, dispatch] │
└─────────────────────────────────────────────┘
mountState と updateState の詳細は前回の記事で解説しました。今回注目するのは、mountState が返した dispatch(= setCount)がユーザーコードから呼ばれたときの処理です。
setState が呼ばれたとき:3つのフェーズ
setState を呼んでから実際に state が更新されるまでは、3つのフェーズに分かれます。
レストランの注文に例えると:
| フェーズ | レストランで言うと | React で言うと |
|---|---|---|
| ❶ 予約 | 注文を伝票に書く | update オブジェクトを作って queue に積む |
| ❷ スケジューリング | キッチンが「次に作る料理」を決める | Scheduler が優先度に基づいてタスクを管理する |
| ❸ 実行 | 料理を実際に作る | state を再計算してコンポーネントを再実行する |
setState (setCount(11))
│
▼
┌─────────────────────────────────────────────────────┐
│ ❶ 予約フェーズ:dispatchSetState │
│ 「何を更新するか」を記録する │
│ → update オブジェクトを生成し、queue に積む │
│ → eager state で bailout 判定(最適化) │
│ → scheduleUpdateOnFiber で再レンダーを依頼 │
└──────────────────────┬──────────────────────────────┘
▼
┌─────────────────────────────────────────────────────┐
│ ❷ スケジューリングフェーズ:Scheduler │
│ 「いつ実行するか」を決める │
│ → lane(優先度)に基づいてタスクを管理 │
│ → ブラウザの適切なタイミングで render を開始 │
└──────────────────────┬──────────────────────────────┘
▼
┌─────────────────────────────────────────────────────┐
│ ❸ 実行フェーズ:renderWithHooks → updateState │
│ 「実際に state を計算する」 │
│ → queue に積まれた update を順番に適用 │
│ → 新しい state を確定し、コンポーネントを再実行 │
└─────────────────────────────────────────────────────┘
ここで一番大事なことは、setState は state を即座に書き換えないということです。
const [count, setCount] = useState(0);
function handleClick() {
setCount(1);
console.log(count); // まだ 0 のまま!
}
なぜすぐに変わらないのか? それは setCount(1) が「count を 1 にしてほしい」という注文伝票を出しているだけだからです。実際に count が 1 になるのは、React が再レンダーを実行したときです。
この3段階の分離により、React は複数の更新をバッチ処理(まとめて処理)したり、優先度の高い更新を先に処理したりできます。
今回は ❶ 予約フェーズ(dispatchSetState) の中身を詳しく読んでいきます。
はじめに:前回のおさらい
前回は、mountState と updateState の処理を読みました。
-
mountState:Hook ノード(state の情報を保持する箱)を生成し、Fiber に紐付け、[state, dispatch]を返す -
updateState:queue に積まれた update(更新リクエスト)を順番に適用し、新しい state を計算して返す
今回は、mountState で生成された dispatch 関数(setCount など)が呼ばれたときに何が起きるかを追います。
【前回まで】
mountState(10)
↓
dispatch = dispatchSetState.bind(null, fiber, queue)
↓
return [10, dispatch]
【今回】
setCount(11) ← あなたが書くコード
↓
dispatchSetState(fiber, queue, 11) ← React が内部で呼ぶ関数
↓
① update オブジェクトを生成 ← 今回
↓
② eager state の計算(最適化) ← 今回
↓
③ queue に enqueue ← 今回
↓
④ 再レンダーをスケジュール ← 今回
補足:
.bind()って何?
dispatchSetState.bind(null, fiber, queue)は、「dispatchSetStateを呼ぶときに、第1引数にfiber、第2引数にqueueを自動的に渡すようにした新しい関数を作る」という意味です。
だからsetCount(11)と書くだけで、内部ではdispatchSetState(fiber, queue, 11)が呼ばれるのです。
dispatchSetState:全体像
まず dispatchSetState の実装を全体で見てみましょう。
実際のソースコードでは、dispatchSetState と dispatchSetStateInternal の2関数に分離されています。
// packages/react-reconciler/src/ReactFiberHooks.js(一部抜粋)
// 外側の薄いラッパー:開発用の警告・計測を担当
function dispatchSetState(fiber, queue, action) {
const lane = requestUpdateLane(fiber); // 優先度を取得
// 実処理を dispatchSetStateInternal に任せる
const didScheduleUpdate = dispatchSetStateInternal(
fiber,
queue,
action,
lane,
);
if (didScheduleUpdate) {
startUpdateTimerByLane(lane, "setState()", fiber); // パフォーマンス計測
}
markUpdateInDevTools(fiber, lane, action); // React DevTools への通知
}
外側の dispatchSetState は「計測・通知」だけを担当する薄い関数で、本当の処理は dispatchSetStateInternal に書かれています。
// 内側の実装本体:ここが今回の主役
function dispatchSetStateInternal(fiber, queue, action, lane) {
// ① update オブジェクトを生成
const update = {
lane,
revertLane: NoLane,
gesture: null,
action,
hasEagerState: false,
eagerState: null,
next: null,
};
if (isRenderPhaseUpdate(fiber)) {
// レンダー中の setState(特殊ケース → 補足で解説)
enqueueRenderPhaseUpdate(queue, update);
} else {
const alternate = fiber.alternate;
// ② eager state の計算(最適化)
if (
fiber.lanes === NoLanes &&
(alternate === null || alternate.lanes === NoLanes)
) {
const lastRenderedReducer = queue.lastRenderedReducer;
if (lastRenderedReducer !== null) {
try {
const currentState = queue.lastRenderedState;
const eagerState = lastRenderedReducer(currentState, action);
update.hasEagerState = true;
update.eagerState = eagerState;
if (is(eagerState, currentState)) {
// state が変わらないなら再レンダーしない(bailout)
enqueueConcurrentHookUpdateAndEagerlyBailout(fiber, queue, update);
return false; // 再レンダーを予約しなかった
}
} catch (error) {
// エラーは抑制して、render フェーズで再スロー
}
}
}
// ③ queue に enqueue し、④ 再レンダーをスケジュール
const root = enqueueConcurrentHookUpdate(fiber, queue, update, lane);
if (root !== null) {
scheduleUpdateOnFiber(root, fiber, lane);
entangleTransitionUpdate(root, queue, lane);
return true; // 再レンダーを予約した
}
}
return false;
}
いきなり全部読む必要はありません。処理は大きく4つのステップに分かれます。順番に見ていきましょう。
① update オブジェクトの生成
setState が呼ばれると、まず**「何を更新したいか」を記録するメモ**を作ります。これが update オブジェクトです。
const lane = requestUpdateLane(fiber);
const update = {
lane, // この更新の優先度(後述)
revertLane: NoLane,
gesture: null,
action, // ★ setState に渡された値 or 関数
hasEagerState: false,
eagerState: null,
next: null, // 次の update へのポインタ
};
たとえば setCount(11) と書くと、action に 11 が入ります。
setCount(prev => prev + 1) と書くと、action にその関数がそのまま入ります。
requestUpdateLane:優先度の決定
const lane = requestUpdateLane(fiber);
React は「この更新はどのくらい急ぎか?」を判断して、優先度(lane)を割り当てます。
用語:lane(レーン)
React は更新の緊急度を「レーン」というビットフラグで管理しています。高速道路の車線(レーン)をイメージしてください。追い越し車線(高優先度)を走る更新は先に処理されます。
| lane | どんなときに割り当てられるか | 例 |
|---|---|---|
SyncLane(最高優先) |
ボタンクリックなど、単発のユーザー操作 |
onClick 内の setState
|
InputContinuousLane |
スクロールやドラッグなど、連続的な操作 |
onScroll 内の setState
|
DefaultLane |
イベントハンドラの外 |
setTimeout や fetch().then() 内の setState
|
TransitionLane(最低優先) |
startTransition 内の更新(中断可能) |
startTransition(() => setState(...)) |
ビットの位置が小さいほど優先度が高く、先に処理されます。
普段の開発では lane を意識する必要はありませんが、React が「クリックの反応はすぐに、重い計算は後回しに」できるのは、この仕組みのおかげです。
update オブジェクトの各フィールド
| フィールド | 役割 | 初学者向けの説明 |
|---|---|---|
lane |
この update の優先度 | どのくらい急ぎの更新か |
revertLane |
useOptimistic などの Transition 用 |
通常の useState では使わない(NoLane) |
gesture |
スワイプ等のジェスチャー操作用 | 実験的機能。通常は null
|
action |
setState に渡された値 or 関数 |
これが一番重要。 更新の「中身」そのもの |
hasEagerState |
事前計算が行われたか | 次の ② で詳しく説明 |
eagerState |
事前計算された state 値 | 次の ② で詳しく説明 |
next |
次の update へのポインタ | 複数の更新をリスト状に繋ぐため |
② eager state:再レンダー前の最適化
ここが dispatchSetState の最も重要で、最も賢い部分です。
まず結論から
React は setState が呼ばれた瞬間に、「この更新、実は state を変えないんじゃない?」をチェックします。もし state が変わらないなら、再レンダーを丸ごとスキップします。
// あなたが書くコード
const [count, setCount] = useState(0);
<button onClick={() => setCount(0)}>Click</button>;
このボタンを何度クリックしても再レンダーは起きません。なぜなら setCount(0) は「count を 0 にして」という注文ですが、count はすでに 0 だからです。React は「何も変わらないなら作り直す必要はない」と判断します。
この最適化を eager state(先行 state 計算)と呼びます。
ソースコードを読む
const alternate = fiber.alternate;
// 条件:「他に処理待ちの更新がない」こと
if (
fiber.lanes === NoLanes &&
(alternate === null || alternate.lanes === NoLanes)
) {
const lastRenderedReducer = queue.lastRenderedReducer;
if (lastRenderedReducer !== null) {
try {
const currentState = queue.lastRenderedState; // 今の state
const eagerState = lastRenderedReducer(currentState, action); // 新しい state を先に計算
update.hasEagerState = true;
update.eagerState = eagerState;
if (is(eagerState, currentState)) {
// state が変わらない → 再レンダーしない!(bailout)
enqueueConcurrentHookUpdateAndEagerlyBailout(fiber, queue, update);
return false;
}
} catch (error) {
// エラーが起きたら最適化を諦め、render フェーズで再スローする
}
}
}
これを分解して見ていきましょう。
ステップ 1:前提条件のチェック
if (
fiber.lanes === NoLanes &&
(alternate === null || alternate.lanes === NoLanes)
)
日本語に訳すと: 「この Fiber(コンポーネント)に、他に処理待ちの更新がないこと」
なぜこの条件が必要なのか? たとえ話で説明します。
たとえ:銀行口座
残高が 1000 円で、2つの処理が同時に来たとします:
- 処理A:500 円引き出す
- 処理B:残高を 1000 円にセットする
処理B だけを見ると「残高は 1000 円のまま → 何も変わらない!」と思えます。
しかし処理A が先に適用されると残高は 500 円になり、処理B で 1000 円に変わります。つまり、他に処理待ちの更新がある場合、「変わらない」という判断が間違っている可能性があるのです。
だから React は「他に pending な更新がない」ときだけ、この最適化を行います。安全側に倒しているわけですね。
【最適化が有効な場合】
処理待ちの更新がない → 今の state から確実に次の state を計算できる
【最適化をスキップする場合】
処理待ちの更新がある → 他の更新が先に適用されるかもしれない
→ 先行計算の結果が正確とは限らない
→ 安全のため最適化を諦める
ステップ 2:state を先に計算する
const currentState = queue.lastRenderedState; // 前回レンダーで確定した state
const eagerState = lastRenderedReducer(currentState, action); // 新しい state を計算
lastRenderedState は「前回のレンダーで確定した state の値」です。
lastRenderedReducer は、前回の記事で見た basicStateReducer です。
basicStateReducer
function basicStateReducer(state, action) { return typeof action === "function" ? action(state) : action; }
actionが関数なら実行し、値ならそのまま返す。
具体例で見てみましょう:
【値を渡した場合】
setCount(11)
currentState = 10 (今の state)
action = 11 (setState に渡した値)
eagerState = basicStateReducer(10, 11)
= 11 (action は関数じゃないのでそのまま返す)
【関数を渡した場合】
setCount(prev => prev + 1)
currentState = 10
action = prev => prev + 1
eagerState = basicStateReducer(10, prev => prev + 1)
= (prev => prev + 1)(10)
= 11
ステップ 3:Object.is で比較して bailout
if (is(eagerState, currentState)) {
enqueueConcurrentHookUpdateAndEagerlyBailout(fiber, queue, update);
return false; // 再レンダーしない
}
ここでの is は JavaScript の Object.is です。
Object.isとは
2つの値が「まったく同じか」を判定する関数です。===(厳密等価)とほぼ同じですが、NaNと-0の扱いが異なります。Object.is(10, 10); // true → 同じ! Object.is(10, 11); // false → 違う Object.is("a", "a"); // true Object.is({}, {}); // false → オブジェクトは参照が違えば別物
===との違い(NaNと-0):// NaN の扱い NaN === NaN; // false ← === では「別物」扱い Object.is(NaN, NaN); // true ← Object.is では「同じ」扱い // -0 の扱い -0 === +0; // true ← === では「同じ」扱い Object.is(-0, +0); // false ← Object.is では「別物」扱い
先に計算した state(eagerState)と今の state(currentState)が同じなら、再レンダー自体をスキップします。これが bailout(ベイルアウト) です。
用語:bailout(ベイルアウト)
「この更新では何も変わらないので、処理を打ち切る」と React が判断すること。再レンダーのコストを避けるための重要な最適化です。
「これ以上進んでも無駄なので離脱する」というイメージです。
bailout 時には enqueueConcurrentHookUpdateAndEagerlyBailout という長い名前の関数が呼ばれます。名前を分解すると:
-
enqueue:update を queue に積む -
ConcurrentHookUpdate:Concurrent Mode の Hook 更新として -
AndEagerlyBailout:かつ、即座に bailout する
update 自体は queue に記録しますが、scheduleUpdateOnFiber(再レンダーの予約)は呼びません。
なぜ bailout するのに update を queue に積むの?
後から別の理由で再レンダーが起きたときに備えるためです。もし他の更新で再レンダーが起きた場合、この update も一緒に処理される必要があるからです。
③ queue への enqueue:循環リンクリスト
bailout にならなかった場合(= state が変わる場合)、update を queue に積んで再レンダーを予約します。
const root = enqueueConcurrentHookUpdate(fiber, queue, update, lane);
enqueue の全体像:2段階で行われる
ここが少し複雑ですが、大事なポイントは「update はすぐにはリンクリストに入らない」ということです。
【2段階の enqueue】
❶ setState が呼ばれたとき:
update を「一時的な配列(バッファ)」に置いておく
※ まだリンクリストには入らない
❷ レンダーが実際に始まるとき:
バッファから取り出して、リンクリストに連結する
なぜ2段階にするのか? それはバッチ処理のためです。
function handleClick() {
setCount(1); // → バッファに追加(まだレンダーしない)
setName("a"); // → バッファに追加(まだレンダーしない)
// ↑ イベントハンドラが終わった後に、まとめて1回だけレンダーされる
}
もし setCount(1) のたびに即座にレンダーが始まると、setName("a") の分もあわせて2回レンダーされてしまいます。バッファに貯めておいてまとめて処理することで、レンダーは 1回 で済みます。
❶ enqueueUpdate:バッファリング配列への追加
// packages/react-reconciler/src/ReactFiberConcurrentUpdates.js(一部抜粋)
// これが「バッファ」の正体:ただのフラットな配列
const concurrentQueues = [];
let concurrentQueuesIndex = 0;
function enqueueUpdate(fiber, queue, update, lane) {
// 配列に4つの要素をセットで追加する
concurrentQueues[concurrentQueuesIndex++] = fiber;
concurrentQueues[concurrentQueuesIndex++] = queue;
concurrentQueues[concurrentQueuesIndex++] = update;
concurrentQueues[concurrentQueuesIndex++] = lane;
// fiber.lanes に「この Fiber には処理待ちの更新がある」とマークする
fiber.lanes = mergeLanes(fiber.lanes, lane);
const alternate = fiber.alternate;
if (alternate !== null) {
alternate.lanes = mergeLanes(alternate.lanes, lane);
}
}
イメージ:レストランの注文控え
注文が来るたびに、控え帳に順番に書いていく: concurrentQueues = [ fiber, queue, update1, lane, ← setCount(1) の注文 fiber, queue, update2, lane, ← setName("a") の注文 ] まだキッチンには伝えていない(リンクリストには入っていない)
ポイント:
fiber.lanesへの lane のマークだけは即座に行われます。これは ② の eager state 最適化で「他に処理待ちの更新があるか」を判定するために必要です(fiber.lanes === NoLanesの条件で使います)。
getRootForUpdatedFiber:ルートの探索
enqueueConcurrentHookUpdate は、update をバッファに積んだ後、Fiber ツリーのルート(一番上の親)を探して返します。
function getRootForUpdatedFiber(sourceFiber) {
// 親を辿って一番上まで行く
let node = sourceFiber;
let parent = node.return; // return = 親への参照
while (parent !== null) {
node = parent;
parent = node.return;
}
return node.tag === HostRoot ? node.stateNode : null;
}
イメージ:会社の組織図を辿る
FiberRootNode(会社全体) ← ここを返す ↓ HostRoot Fiber(本社) ↓ App Fiber(部署) ↓ Counter Fiber(自分) ← setState を呼んだのはここ「自分 → 部署 → 本社 → 会社全体」と辿って、最上位のノードを返します。
再レンダーは常にルートから始まるため、ルートの情報が必要なのです。
ポイント:
throwIfInfiniteUpdateLoopDetected()は、同じコンポーネントが短時間に大量の再レンダーを起こしていないかチェックします。「Too many re-renders」エラーを見たことがあるなら、それはここで検知されています。
❷ finishQueueingConcurrentUpdates:実際のリンクリスト連結
バッファに貯められた update が実際にリンクリストに連結されるのは、レンダー開始時です。
// packages/react-reconciler/src/ReactFiberConcurrentUpdates.js(一部抜粋)
export function finishQueueingConcurrentUpdates() {
const endIndex = concurrentQueuesIndex;
concurrentQueuesIndex = 0; // バッファをリセット
let i = 0;
while (i < endIndex) {
// バッファから4要素ずつ取り出す
const fiber = concurrentQueues[i];
concurrentQueues[i++] = null;
const queue = concurrentQueues[i];
concurrentQueues[i++] = null;
const update = concurrentQueues[i];
concurrentQueues[i++] = null;
const lane = concurrentQueues[i];
concurrentQueues[i++] = null;
// ↑ なぜ毎回 null を入れている?
// concurrentQueues はモジュールスコープの「使い回し配列」。
// 値を取り出した後も配列自体は生き続けるため、
// null を入れて参照を切らないと、不要になった fiber や update が
// GC(ガベージコレクション)に回収されず、メモリリークの原因になる。
if (queue !== null && update !== null) {
const pending = queue.pending;
if (pending === null) {
// 最初の update → 自分自身を指す(自己循環)
update.next = update;
} else {
// 既にリストがある → 末尾に追加
update.next = pending.next;
pending.next = update;
}
queue.pending = update;
}
}
}
ここでようやく、update が循環リンクリストとして繋がります。
「循環リンクリスト」って何? と思った方、安心してください。次のセクションで丁寧に説明します。
【まとめ:2段階の enqueue】
❶ setState 呼び出し時(enqueueUpdate)
→ concurrentQueues 配列にバッファリング
→ fiber.lanes に lane をマーク
❷ レンダー開始時(finishQueueingConcurrentUpdates)
→ バッファから取り出して循環リンクリストに連結
循環リンクリストとは
まず、普通のリンクリストから説明します。
用語:リンクリスト
各要素(ノード)が「次の要素へのポインタ(参照)」を持つデータ構造です。配列とは違い、要素の追加・削除がポインタの付け替えだけで済むため効率的です。
【普通のリンクリスト】
A → B → C → null(ここで終わり)
【循環リンクリスト】
A → B → C → A(先頭に戻る!)
↑ │
└──────────────┘
普通のリンクリストは末尾が null で終わりますが、循環リンクリストは末尾が先頭に戻る構造です。
React の update queue では、queue.pending は常に最後に追加された update(末尾)を指します。そして末尾の next が先頭を指すので、queue.pending.next で先頭にもアクセスできるのがポイントです。
queue.pending(= 末尾を指す)
↓
末尾 ──→ 先頭 → ... → 末尾(ぐるっと回る)
enqueue の仕組み:ポインタ操作を1ステップずつ追う
update を追加するときのポインタ操作を、1つずつ丁寧に追ってみましょう。
// 新しい update を末尾に追加する
function enqueue(queue, update) {
const pending = queue.pending;
if (pending === null) {
// リストが空 → update が唯一の要素、自分自身を指す
update.next = update;
} else {
// リストに要素がある
// ① 新しい update の next を「現在の先頭」に向ける
update.next = pending.next;
// ② 現在の末尾の next を新しい update に付け替える
pending.next = update;
}
// ③ pending(末尾ポインタ)を新しい update に更新
queue.pending = update;
}
【update1 を追加:リストが空の場合】
queue.pending = null(まだ何もない)
enqueue(queue, update1)
pending = null → 空リストなので自己循環を作る
update1.next = update1 ← 自分自身を指す
queue.pending = update1
結果:
queue.pending
↓
update1 ─→ update1(自分に戻る)
↑ │
└────────────┘
先頭 = queue.pending.next = update1
末尾 = queue.pending = update1
(要素が1つなので先頭 = 末尾)
【update2 を追加:要素が1つある場合】
enqueue(queue, update2)
pending = update1(現在の末尾)
① update2.next = pending.next
= update1.next
= update1 ← 現在の先頭(自分を指していた)
つまり:update2 → update1
② pending.next = update2
update1.next = update2
つまり:update1 → update2
③ queue.pending = update2 ← 末尾ポインタを新しい末尾に更新
結果:
queue.pending
↓
update2 ──→ update1
↑ │
└──────────────┘
先頭 = queue.pending.next = update1 ← 追加順で最初
末尾 = queue.pending = update2 ← 追加順で最後
【update3 を追加:要素が2つある場合】
enqueue(queue, update3)
pending = update2(現在の末尾)
① update3.next = pending.next = update1(先頭)
② pending.next = update3(末尾の next を新しい update へ付け替え)
③ queue.pending = update3
結果:
queue.pending
↓
update3 ──→ update1 ──→ update2
↑ │
└─────────────────────────┘
先頭 = queue.pending.next = update1
末尾 = queue.pending = update3
新しい update は常に「末尾」に入り、先頭の順番は変わりません。
queue.pendingは常に末尾を指し、queue.pending.nextで先頭にアクセスできます。
走査の仕組み:どこで止まるか
循環リンクリストには null(終端)がないので、「いつ止まるか」を自分で決める必要があります。
React は先頭を記録しておいて、一周したら終了という方法を取っています。
// 走査の擬似コード
const first = queue.pending.next; // 先頭を記録
let update = first;
do {
// update を処理する
update = update.next;
} while (update !== first); // 先頭に戻ったら終了
走査順(update1, update2, update3 がある場合):
① first = update1(先頭を記録)
② update1 を処理 → 次は update2
③ update2 を処理 → 次は update3
④ update3 を処理 → 次は update1
⑤ update === first → 一周した!終了
なぜ循環リンクリストを使うのか
普通の配列やリンクリストじゃダメなの?
普通のリンクリスト(head ポインタだけ)の場合:
head
↓
A → B → C → null
末尾に追加するには、head から C まで辿る必要があります。要素が n 個あれば n 回辿る(O(n))ので遅いです。
head と tail の2ポインタにすれば?
head tail
↓ ↓
A → B → C → null
末尾追加は O(1) になりますが、ポインタを2つ管理する必要があります。
循環リンクリスト(React の方式):
queue.pending(= 末尾)
↓
C ──→ A → B → C
先頭 = pending.next = A
末尾 = pending = C
1つのポインタだけで先頭も末尾も O(1) でアクセスできます。 2ポインタ方式と同じ性能を、ポインタ半分のコストで実現しているのです。
さらに、循環リンクリストにはリストの結合が O(1) というメリットもあります。前回の記事で見た updateReducerImpl では、pending(新しい更新)と baseQueue(前回の残り)を結合する処理がありました。
// updateReducerImpl 内での結合処理(前回の記事より)
if (pendingQueue !== null) {
if (baseQueue !== null) {
const baseFirst = baseQueue.next; // ① baseQueue の先頭を記録
const pendingFirst = pendingQueue.next; // ② pendingQueue の先頭を記録
baseQueue.next = pendingFirst; // ③ base の末尾 → pending の先頭
pendingQueue.next = baseFirst; // ④ pending の末尾 → base の先頭
}
current.baseQueue = baseQueue = pendingQueue;
queue.pending = null;
}
ポインタの付け替えだけで2つのリストが1本に繋がります。
【結合前】2つの独立した循環リスト
baseQueue: B2 ──→ B1 ──→ B2(ぐるぐる)
pendingQueue: P2 ──→ P1 ──→ P2(ぐるぐる)
【結合後】1つの大きな循環リスト
P2 ──→ B1 ──→ B2 ──→ P1 ──→ P2(ぐるぐる)
走査順:B1 → B2 → P1 → P2
※ base の更新が先、pending の更新が後
ポイント:結合後のリストは「base の update → pending の update」の順になります。前回レンダーで保留になった更新を先に適用し、今回の新しい更新を後から適用する。これにより state の整合性が保たれます。
④ scheduleUpdateOnFiber:再レンダーのスケジュール
③ で update をバッファに積んだら、次は React に「再レンダーしてください」と依頼します。
const root = enqueueConcurrentHookUpdate(fiber, queue, update, lane);
if (root !== null) {
scheduleUpdateOnFiber(root, fiber, lane);
entangleTransitionUpdate(root, queue, lane);
}
root !== null(= コンポーネントがマウント済み)であれば、scheduleUpdateOnFiber で再レンダーを予約します。
scheduleUpdateOnFiber の実装
この関数は長いですが、通常の setState ではB と D しか通らないので、そこに注目しましょう。
// packages/react-reconciler/src/ReactFiberWorkLoop.js(一部抜粋)
export function scheduleUpdateOnFiber(root, fiber, lane) {
// ── A. サスペンド中のレンダーを中断 ──(通常は通らない)
if (...) {
prepareFreshStack(root, NoLanes);
markRootSuspended(...);
}
// ── B. root に「更新あり」をマーク ── ★ ここは必ず通る
markRootUpdated(root, lane);
if ((executionContext & RenderContext) !== NoContext && root === workInProgressRoot) {
// ── C. レンダー中に呼ばれた場合 ──(特殊ケース、通常は通らない)
workInProgressRootRenderPhaseUpdatedLanes = mergeLanes(...);
} else {
// ── D. 通常の更新 ── ★ ここを通る
ensureRootIsScheduled(root); // Scheduler に再レンダーを依頼
}
}
B. markRootUpdated:root への lane 登録
markRootUpdated(root, lane);
この関数は、ルートの pendingLanes に「処理すべき更新がある」とマークします。
用語:
pendingLanes
Fiber ツリーの最上位ノード(FiberRootNode)が持つフィールドで、「まだ処理されていない更新の優先度」をビットフラグで保持しています。
markRootUpdated の前後:
更新前:root.pendingLanes = 0b0000000 (未処理の更新なし)
↓
markRootUpdated(root, DefaultLane)
↓
更新後:root.pendingLanes = 0b0100000 (DefaultLane がマークされた)
たとえ:タスクボード
root.pendingLanesは、タスクボードの「To Do」列のようなものです。
markRootUpdatedは「新しいタスクを To Do に貼る」操作です。
A. サスペンド中のレンダーの中断(通常は通らない)
React が Suspense でデータの読み込みを待っている最中に新しい setState が来た場合、現在のレンダーを中断してやり直す処理です。通常の useState では通りません。
C. レンダー中の更新(通常は通らない)
レンダーフェーズの実行中に setState が呼ばれた場合の特殊処理です。これも通常は通りません。
D. 通常の更新:ensureRootIsScheduled(★ ここが本流)
ensureRootIsScheduled(root);
この関数は React の Scheduler(タスクスケジューラ)に「この root に処理すべき仕事がある」と通知します。
ensureRootIsScheduled(root)
↓
root.pendingLanes を確認(さっき B で登録した lane がある)
↓
最も優先度の高い lane を選択
↓
Scheduler にタスクを登録
↓
ブラウザの適切なタイミングで render 開始
↓
render 開始時に finishQueueingConcurrentUpdates() が呼ばれる
↓
バッファリングされた update が循環リンクリストに連結される(③で解説した処理)
重要な性質:
ensureRootIsScheduledは冪等(べきとう)
すでにタスクが登録済みで、同じ優先度であれば、新たなタスクは登録されません。function handleClick() { setCount(1); // → ensureRootIsScheduled → タスク登録 setName("a"); // → ensureRootIsScheduled → すでに登録済みなので何もしない }だから同じイベントハンドラ内で複数の
setStateを呼んでも、レンダーは1回だけです。
markUpdateLaneFromFiberToRoot:lane の伝播
③ の finishQueueingConcurrentUpdates の中で、markUpdateLaneFromFiberToRoot が呼ばれ、Fiber ツリーに lane を伝播させます。
function markUpdateLaneFromFiberToRoot(sourceFiber, update, lane) {
// ① 更新元の Fiber に lane をマーク
sourceFiber.lanes = mergeLanes(sourceFiber.lanes, lane);
// ② 親を辿りながら childLanes をマーク
let parent = sourceFiber.return;
while (parent !== null) {
parent.childLanes = mergeLanes(parent.childLanes, lane);
parent = parent.return;
}
}
用語:
mergeLanes
ビット OR 演算(a | b)で lane を合成する関数です。既存の lane を消さずに、新しい lane を追加できます。
たとえばlanesが0b0000000(何もなし)のとき、mergeLanes(0b0000000, 0b0000001)→0b0000001となり、DefaultLane がマークされます。
用語:
childLanes
「この Fiber の子孫のどこかに、まだ処理されていない更新がある」ことを示すフラグです。
setState を呼んだのが Counter コンポーネントの場合:
HostRoot ← childLanes に DefaultLane をマーク
↓
App ← childLanes に DefaultLane をマーク
↓
Counter ← lanes に DefaultLane をマーク(更新元)
↓
div (何もしない)
「Counter が更新されるよ」という連絡を、Counter → App → HostRoot と親に伝えていきます。
こうしておくと、レンダー時に「この子孫に更新がないな」と分かった Fiber はサブツリーごとスキップできます。大きなアプリでは、この最適化が非常に重要です。
全体の流れ
setState が呼ばれてからレンダーが開始されるまでの流れを改めて整理します。
setCount(11)
↓
dispatchSetState(fiber, queue, 11)
↓
enqueueConcurrentHookUpdate(fiber, queue, update, lane)
├─ enqueueUpdate: concurrentQueues 配列にバッファリング
├─ fiber.lanes に lane を即座にマーク
└─ getRootForUpdatedFiber: Fiber ツリーを遡って FiberRootNode を返す
↓
scheduleUpdateOnFiber(root, fiber, lane)
├─ markRootUpdated: root.pendingLanes に lane を追加
└─ ensureRootIsScheduled: Scheduler にタスクを登録
↓
─── ブラウザの適切なタイミングで render 開始 ───
↓
finishQueueingConcurrentUpdates()
├─ concurrentQueues からバッファを取り出す
├─ queue.pending に循環リンクリストとして連結
└─ markUpdateLaneFromFiberToRoot: Fiber ツリーに childLanes を伝播
↓
renderWithHooks() → updateState() → queue.pending の update を処理
entangleTransitionUpdate
entangleTransitionUpdate(root, queue, lane);
この関数は startTransition 内の更新に対してのみ意味を持ちます。通常の useState の更新では実質的に何もしないので、今は説明を省きます。
補足:レンダー中の setState(render phase update)
if (isRenderPhaseUpdate(fiber)) {
enqueueRenderPhaseUpdate(queue, update);
}
dispatchSetState の冒頭には、レンダー中(コンポーネント関数の実行中)に setState が呼ばれた場合の分岐があります。
// こういうコードを書くと render phase update になる
function Counter() {
const [count, setCount] = useState(0);
if (count < 5) {
setCount(count + 1); // ← レンダー中に setState を呼んでいる!
}
return <div>{count}</div>;
}
通常、setState はイベントハンドラ(onClick など)の中で呼びます。しかし上の例では、コンポーネント関数の本体(= レンダー中)で直接呼んでいます。
この場合、通常のフロー(バッファに積んで Scheduler に依頼)ではなく、現在のレンダーサイクル内で即座に処理されます。React は同じコンポーネントを再実行し、state が安定するまで繰り返します。
注意:無限ループの危険
function Bad() { const [count, setCount] = useState(0); setCount(count + 1); // ← 条件なしで毎回呼ぶと無限ループ! return <div>{count}</div>; }React は再実行回数の上限(25回)を設けており、超えると「Too many re-renders」エラーが発生します。
全体の流れ:setCount(11) の一生
ここまでの内容を、setCount(11) が呼ばれてから再レンダーで state が更新されるまでの全体フローとして整理します。
setCount(11) あなたが書いたコード
↓
dispatchSetState(fiber, queue, 11) React が内部で呼ぶ関数
↓
① update オブジェクトを生成
{ lane: DefaultLane, action: 11, hasEagerState: false, ... }
(「count を 11 にして」という更新情報をオブジェクトにまとめる)
↓
② eager state を計算(最適化)
eagerState = basicStateReducer(10, 11) = 11
Object.is(11, 10) → false → 値が変わる → bailout しない
(「本当に変わるの?」をチェック → 変わるのでレンダーが必要)
↓
③ enqueueConcurrentHookUpdate
├─ enqueueUpdate: concurrentQueues 配列にバッファリング
│ (update を一時配列に溜めておく)
├─ fiber.lanes |= DefaultLane(即座にマーク)
│ (「この Fiber には処理待ちがある」と記録)
└─ getRootForUpdatedFiber: Fiber ツリーを遡って root を返す
(親を辿って FiberRoot を取得する)
↓
④ scheduleUpdateOnFiber(root, fiber, lane)
├─ markRootUpdated: root.pendingLanes |= DefaultLane
│ (root に「この lane の更新がある」と記録する)
└─ ensureRootIsScheduled: Scheduler にタスクを登録
(「やることあるよ」と Scheduler に伝える)
↓
─── ブラウザの適切なタイミングで render 開始 ───
↓
finishQueueingConcurrentUpdates()
├─ concurrentQueues からバッファを取り出す
├─ queue.pending に循環リンクリストとして連結
│ (バッファから update を取り出し、queue に連結する)
└─ markUpdateLaneFromFiberToRoot: childLanes を root まで伝播
(更新元から root まで childLanes をマークする)
↓
renderWithHooks()
↓
Dispatcher を HooksDispatcherOnUpdate にセット
↓
コンポーネント関数を再実行
↓
useState(10) ← initialState の 10 は無視される(再レンダーなので)
↓
updateState(10) → updateReducer → updateReducerImpl
↓
queue.pending の update を適用
hasEagerState === true → eagerState = 11 をそのまま使う
(② で先に計算しておいた値を再利用。同じ計算を二度しなくて済む)
↓
hook.memoizedState = 11
↓
return [11, dispatch] ← count が 11 になった!
bailout が発生するケース
比較として、bailout(再レンダーのスキップ)が発生するケースも見てみましょう。
const [count, setCount] = useState(10);
setCount(10); // 現在の state と同じ値
setCount(10)
↓
dispatchSetState(fiber, queue, 10)
↓
① update オブジェクトを生成
{ lane: DefaultLane, action: 10, ... }
↓
② eager state を計算
eagerState = basicStateReducer(10, 10) = 10
Object.is(10, 10) → true → 値が変わらない → bailout!
(「変わらないならレンダーしなくていいよね」)
↓
③ enqueueConcurrentHookUpdateAndEagerlyBailout
├─ update を queue に積む(後で別の理由でレンダーされたときのため)
└─ レンダー中でなければ finishQueueingConcurrentUpdates() を即実行
→ update を循環リンクリストに連結(メモリリーク防止)
↓
④ scheduleUpdateOnFiber は呼ばれない → 再レンダーなし!
bailout 時のポイント
- update 自体は queue に残します(後で別の理由で再レンダーが起きたときに備えて)
finishQueueingConcurrentUpdatesが即座に呼ばれます。通常はレンダー開始時に呼ばれる関数ですが、bailout の場合はレンダーが起きないので、バッファに残った update がメモリリークしないよう即座にリンクリストに連結します
まとめ:dispatchSetState の全体像
dispatchSetState(fiber, queue, action)
│
├─ update オブジェクトを生成
│ 「何を更新したいか」を記録したメモ
│
├─ レンダー中の setState?
│ → Yes: 現在のレンダー内で即処理(特殊ケース)
│ → No: 通常フローへ
│
├─ 他に処理待ちの更新がない?
│ → Yes: eager state を計算
│ state が変わらない? → bailout(再レンダーしない)
│ → No: 最適化をスキップ(安全側に倒す)
│
├─ queue に enqueue(バッファ → 循環リンクリスト)
│
└─ scheduleUpdateOnFiber → Scheduler に再レンダーを依頼
この記事のポイント
-
setStateは state を即座に変えない。更新の「予約」を登録するだけ。実際の計算は再レンダー時に行われる - eager state により、state が変わらない場合は再レンダーを丸ごとスキップできる(bailout)
- enqueue は2段階で行われる。バッファに貯めてからまとめてリンクリストに連結することで、バッチ処理を実現している
- 循環リンクリストは1つのポインタで先頭・末尾の両方に O(1) でアクセスでき、リストの結合も O(1) で行える効率的なデータ構造
次回予告
次回は 優先度(Lanes)と中断・再開 を読んでいく予定です。