本記事は、React の内部実装を理解するための学習ログです。
useState シリーズの dispatchSetState 編で登場した「lane(優先度)」の正体を、ソースコードから追っていきます。
はじめに
前回の記事で、dispatchSetState の処理を3つのフェーズに分けて解説しました。
❶ 予約フェーズ:dispatchSetState
❷ スケジューリングフェーズ:Scheduler
❸ 実行フェーズ:renderWithHooks → updateState
その中で「❷ スケジューリングフェーズ」では、lane(優先度)に基づいてタスクを管理すると説明しましたが、lane そのものの仕組みには踏み込みませんでした。
この記事では、その lane — React の優先度管理システム Lanes の内部を掘り下げます。
この記事でわかること:
- Lanes とは何か — 更新の優先度をビットで管理する仕組み
- なぜ旧モデル(ExpirationTime)から移行したのか
- ビットマスク演算で何が嬉しいのか
- 中断・再開(Concurrent Rendering)はどう実現されるのか
- useState の dispatchSetState で lane がどう使われるか
前提知識:
- React の基本(useState, useEffect)
- Fiber、Hook、キューの概念(useState 概要編の内容レベル)
- ビット演算の基礎(AND, OR)があると望ましいが、本記事でも補足します
Lanes の全体像 — 「優先度をビットで管理する」
React は更新(state の変更など)に優先度を付けます。
ユーザーのクリックは即座に反映したいが、バックグラウンドの計算は後回しでもいい。
この「どの更新を先に処理するか」を管理するのが Lanes です。
Lane の正体は 32 ビット整数の 1 ビット です。bit 位置と Lane の対応は以下のとおりです。
| bit | Lane | 優先度 |
|---|---|---|
| 0 | SyncHydrationLane |
最高 |
| 1 | SyncLane |
最高 |
| 2 | InputContinuousHydrationLane |
高 |
| 3 | InputContinuousLane |
高 |
| 4 | DefaultHydrationLane |
中 |
| 5 | DefaultLane |
中 |
| 6 | GestureLane |
中 |
| 7 | TransitionHydrationLane |
低 |
| 8〜21 |
TransitionLane(14本) |
低 |
| 22〜27 |
RetryLanes / SelectiveHydrationLane / IdleHydrationLane 等(省略) |
— |
| 28 | IdleLane |
最低 |
| 29 | OffscreenLane |
最低 |
| 30 | DeferredLane |
最低 |
この表の読み方 — bit 番号が小さいほど「最高」優先度です
上の表は bit 番号の昇順で並んでいます。bit 番号が小さい(上にある)ほど優先度が高い点に注意してください。日常の「数値が大きいほど偉い」感覚とは逆ですが、実際のビット列の見た目と一致しています。
実際のビット列(右詰め表記):
0b0000000000000000000000000000010 ← SyncLane(bit 1)
↑ ↑
bit 30(最低・左端) bit 0/1(最高・右端)
bit 番号が小さい = 下位ビット = 最高優先度
用語:Lane と Lanes ─
Laneは 1 ビットだけが立った整数(1 つの優先度レベル)。Lanesは複数の Lane をビット OR で合成したマスク(優先度のセット)。
各 Lane の意味 — いつ、何に使われるか
Lane には 30 種類以上ありますが、まずは日常的によく登場する主要な Laneを押さえれば十分です。以下の表で「どんな操作が、どの Lane に割り当てられるか」を整理します。
| Lane | bit | 優先度 | 割り当てられる操作 | 具体例 |
|---|---|---|---|---|
SyncLane |
1 | 最高 | DiscreteEvent(単発の離散イベント) | ボタンの onclick、onchange、キーボード入力 |
InputContinuousLane |
3 | 高 | ContinuousEvent(連続する入力イベント) |
onscroll、onmousemove、ondrag
|
DefaultLane |
5 | 中 | 明示されない通常の更新 |
setTimeout 内の setState、Promise の .then 内の setState
|
TransitionLane(14本) |
8〜21 | 低 |
startTransition / useTransition 内の更新 |
検索結果の絞り込み、タブ切り替え、ページ遷移準備 |
IdleLane |
28 | 最低 | アイドル時に処理してよい更新 | バックグラウンドのプリフェッチ、解析ログ送信 |
DeferredLane |
30 | 最低 |
useDeferredValue で遅延された値の更新 |
重い検索結果の表示を入力のレスポンスより後回しに |
その他の Lane(補助的な役割):
-
SyncHydrationLane/DefaultHydrationLane/InputContinuousHydrationLane/TransitionHydrationLane— SSR(サーバーサイドレンダリング)されたHTMLをハイドレーション(クライアント側でインタラクティブ化)する際に使われる。通常の更新とハイドレーションを区別するために専用の Lane が用意されている -
GestureLane— View Transition API と連動するジェスチャー更新用(React の最新機能、実験的) -
OffscreenLane—<Offscreen>(非表示領域)の更新用
ポイント: まず覚えるべきは
SyncLane(クリック等)・DefaultLane(通常の setState)・TransitionLane(startTransition 内)の 3 つ。これだけで本記事の以降の説明は 8 割理解できます。他の Lane は「それぞれの用途に最適化された専用レーン」程度の認識で OK です。
// packages/react-reconciler/src/ReactFiberLane.js(一部抜粋)
export type Lanes = number;
export type Lane = number;
export const SyncLane: Lane = 0b0000000000000000000000000000010; // bit 1
export const DefaultLane: Lane = 0b0000000000000000000000000100000; // bit 5
export const IdleLane: Lane = 0b0010000000000000000000000000000; // bit 28
ビット位置が小さいほど優先度が高い。SyncLane(bit 1)が最高優先度で、DeferredLane(bit 30)が最低優先度です。
ユーザーのクリック(DiscreteEvent)は SyncLane、startTransition 内の更新は TransitionLane のうち 1 本が割り当てられます。
なぜ「ただの数値」では足りないのか
ここで気になるのは、「なぜビット位置で表現するのか。数値 1, 2, 3... で優先度を表してもよかったのでは?」という点です。
実は、それが React の旧モデル(ExpirationTime)でした。しかし数値 1 本だと、全ての更新が同じ数直線に並ぶため、ある更新を止めたとき、それより後ろの更新が連鎖的にブロックされてしまいます。
これを解消するために、Lanes では「独立した複数レーン」という発想を採用しました。その動機を次のセクションで掘り下げます。
なぜビットマスクなのか — ExpirationTime からの移行
Lanes の前身は ExpirationTime というモデルでした。各更新にタイムスタンプ(期限)を付け、「期限が早い = 優先度が高い」と数値で比較するシンプルな仕組みです。
ExpirationTime の線形性を具体例で見る
「線形」「数直線」という言葉だけだと抽象的なので、具体例で確認します。
ExpirationTime モデル(1本の数直線に全更新が並ぶ):
t=100 t=200 t=300 t=500
│ │ │ │
▼ ▼ ▼ ▼
更新A 更新B 更新C 更新D
(高優先度) (中優先度) (中優先度) (低優先度)
│
└─ Suspend中(データ取得待ち)
比較ロジック:
「次は t が一番小さい(=期限が早い=優先度が高い)ものを処理」
→ A が先頭。でも A は Suspend 中。
→ A が止まっている間、t が大きい B, C, D は「A より後」なので待たされる
全更新がたった1つのタイムスタンプという軸に並んでいるため、先頭が詰まると後続も詰まります。これが「線形」という言葉の意味です。
「でも、Suspend した更新だけ後回しにして、他は進められそうでは?」と感じる人も多いはず。実はこの直感は正しく、Lanes はまさにこれを実現した改修です。逆に言えば ExpirationTime にはそれができない3つの構造的制約がありました。
-
レンダーは Fiber ツリーの1回走査でバッチ全体を計算する ─
t <= nowの全更新をひとまとめにし、Fiber ツリーを走査して最終 state を計算します。「A だけ抜いて B, C を進める」にはツリーを2回別々に走査する仕組みが必要ですが、当時はそれがありませんでした -
state の連鎖適用を途中スキップする仕組みがない ─
setState(prev => prev + 1)のような更新は前の結果に依存するため、間を抜くには「ここまで計算した state を保存して、後で続きを計算する」バッファが必要です。これは Lanes で導入されたbaseState/baseQueueに相当しますが、ExpirationTime には存在しませんでした - 個別更新の細粒度な持ち越し制御が未整備 ─ タイムスタンプという1つの軸では「どの更新が Suspend したか」を識別しづらく、Suspense との連携が荒い粒度でしか実装できませんでした
結果として ExpirationTime では「先頭が Suspend したらバッチ全体を待つ」しかできませんでした。Lanes はこれら3つの制約をビット単位の独立した Lane + baseState / baseQueue による持ち越しで根本から作り直した設計です。
この先の説明で前提となる用語を整理しておきます。
| 用語 | 意味 |
|---|---|
| Concurrent Rendering(並行レンダリング) | React 18 で正式導入された、レンダー処理を途中で中断して他の優先度の高い処理に譲れる仕組み。従来は一度始まったレンダーは最後まで止められなかった |
| Suspense | データ取得中のコンポーネントを「待機状態」にし、フォールバックUI(ローディング表示など)を出す仕組み。待機中のコンポーネントを含む更新は「Suspend した」状態になり、データが揃うまでコミットできない |
| IO バウンド / CPU バウンド | IO バウンド = ネットワーク等の外部要因で待たされる処理、CPU バウンド = JavaScript の計算で時間がかかる処理 |
高速道路の車線アナロジー
Lanes の設計思想は「高速道路の車線」に例えるとわかりやすいです。
- ExpirationTime(1車線のみ): 1車線しかないので、前の車が事故で止まると後続車が全員巻き添えで止まる
- Lanes(14車線ある): 1車線が事故で止まっても、他の車線は独立して流れ続ける
React の内部でも「どの更新を今走らせ、どの更新を止めておくか」を並行に扱えることが重要でした。以下、具体的にどんな場面でこの違いが効くのかを見ていきます。
問題 1: IO バウンドと CPU バウンドの混在
React 18 の Concurrent Rendering で Suspense が本格的に活用されるようになり、「データを取得中(IO バウンド)」な更新と「UI の計算中(CPU バウンド)」な更新が混在するようになりました。
ExpirationTime モデルでは:
高優先度 IO(Suspense: データ取得中) ← Suspend して待機中
低優先度 CPU(UIの計算) ← 実行可能だが、上がブロック
高優先度の IO バウンドタスクが Suspend している間、それより低優先度の CPU バウンドタスクまで実行できないという問題が起きていました。1車線しかない高速道路で前の車が故障しているのと同じ状態です。
問題 2: 複数の Transition が相互ブロック
ExpirationTime の線形比較では、あるトランジション A が Suspend すると、無関係なトランジション B まで巻き添えでブロックされていました。
具体例で考えてみます。シナリオは「❶ 検索ボックスに入力(startTransition A、データ取得中で Suspense 待機)と、❷ 同時にタブを切り替え(startTransition B、即実行可能)が並行して発生」というケースです。
ExpirationTime 時代: A が Suspend しているので B も実行できない → ユーザーはタブ切り替えができず固まって見える。
Lanes 時代: A と B が別の車線を使うので、A の停止が B に伝播しない → タブ切り替えができる。
解決策 — 14本の独立した TransitionLane
Lanes はビットマスクにより、「どの更新を処理するか」と「どの更新を後回しにするか」を非線形に組み合わせられます。
// 14 本の独立した TransitionLane
// packages/react-reconciler/src/ReactFiberLane.js(一部抜粋)
const TransitionLanes: Lanes = 0b0000000001111111111111100000000;
なぜ14本なのか? — 現実のアプリで同時進行しうるトランジション(検索、タブ切り替え、フィルタ変更、ページ遷移準備など)を十分にカバーできる本数として設計されています。14本あれば、1つが Suspend しても残り13本の車線で他のトランジションを進められます。
| 問題 | ExpirationTime | Lanes |
|---|---|---|
| IO/CPU の混在 | ブロックが発生 | Suspended/Pinged でビットを分けて管理 |
| Transition 間の独立性 | 線形依存 | 14 本の独立した TransitionLane |
| 複数更新のバッチ化 | タイムスタンプ比較 | 同一 Lane → ビット AND で即判定 |
| 飢餓防止 | 複雑な個別処理 |
markStarvedLanesAsExpired で統一管理 |
ポイント: Lanes 導入の最大の動機は「IO バウンドと CPU バウンドの混在問題」の解決でした。線形比較では表現できなかった「この更新は止めるが、あの更新は進める」という非線形な制御が、ビットマスクで可能になったのです。
補足: 飢餓防止 — markStarvedLanesAsExpired
低優先度の Lane が永遠に後回しにされないよう、React には飢餓防止機構があります。
// packages/react-reconciler/src/ReactFiberLane.js(一部抜粋)
export function markStarvedLanesAsExpired(root, currentTime) {
// 各 Lane にタイムスタンプを付与し、
// 一定時間処理されなかった Lane を expiredLanes に昇格させる
if (expirationTime <= currentTime) {
root.expiredLanes |= lane; // → 次のレンダーで強制的に同期実行
}
}
一定時間処理されない Lane は expiredLanes に昇格し、次のレンダーで強制的に同期実行されます。これにより、優先度が低い更新でもいつかは必ず処理されることが保証されています。
ビット演算の基本操作 — ソースコードを読む
Lanes の操作は、すべてビット演算で行われます。主要な関数を見ていきます。
合成・判定
ここで注目すべきポイントは、すべての操作が O(1) のビット演算だという点です。
// packages/react-reconciler/src/ReactFiberLane.js(一部抜粋)
// 2つの優先度セットを合成する
export function mergeLanes(a, b) {
return a | b;
}
// 共通する優先度だけを取り出す
export function intersectLanes(a, b) {
return a & b;
}
// subset の全ビットが set に含まれるか
export function isSubsetOfLanes(set, subset) {
return (set & subset) === subset;
}
たとえば「SyncLane と DefaultLane の両方を今回処理する」なら:
const renderLanes = mergeLanes(SyncLane, DefaultLane);
// 0b...010 | 0b...100000 = 0b...100010
最高優先度の取り出し — 2の補数トリック
// packages/react-reconciler/src/ReactFiberLane.js(一部抜粋)
export function getHighestPriorityLane(lanes) {
return lanes & -lanes;
}
これは 2の補数トリックと呼ばれる技法で、lanes & -lanes によりビット列のうち**最も右にある 1(最下位ビット)**だけを取り出せます。2の補数表現で -lanes は「全ビット反転 + 1」なので、最下位の 1 の位置だけが元と一致するためです。
lanes = 0b00101000 (bit 3 と bit 5 が立っている)
-lanes = 0b11011000 (2の補数)
AND = 0b00001000 (bit 3 だけ = 最高優先度)
Lanes では「ビット位置が小さい = 優先度が高い」ので、最下位ビット = 最高優先度です。
Lane の選択ロジック — 「次に何を処理するか」
ビット演算の基本操作を使って、React は「次にどの Lane を処理するか」を決定します。
getNextLanes の処理フロー
getNextLanes は、保留中の全更新(pendingLanes)からフィルタリングを行い、次に処理すべき Lane のセットを返します。
このセクションで登場する用語を先に整理しておきます。
| 用語 | 意味 |
|---|---|
pendingLanes |
「処理待ち」の全 Lane の集合(ビットマスク)。setState が呼ばれると、その更新の Lane がここに立つ |
suspendedLanes |
Suspense によって「データ取得待ち」でブロックされた Lane。Promise を待っている状態 |
pingedLanes |
suspendedLanes のうち「待っていた Promise が解決した」Lane。再び処理候補に戻る |
wipLanes |
Work In Progress Lanes。現在レンダー作業中の Lane。途中まで処理が進んでいる |
NonIdleLanes |
IdleLane / OffscreenLane 等を除いた「通常の更新用」Lane の集合マスク |
unblocked |
「ブロックされていない」状態。pendingLanes から suspendedLanes を除いた、今すぐ処理可能な Lane |
これらの関係を図で整理すると以下のようになります。
処理の流れは以下のとおりです。出発点は常に pendingLanes(処理待ちの全 Lane)であり、そこから条件に合うものを順に絞り込んでいくという視点で追うとわかりやすいです。
【絞り込みの全体像 — pendingLanes が入力、nextLanes が出力】
pendingLanes (全保留更新 = 入力)
│
│ & NonIdleLanes ← Idle/Offscreen 系を除外
▼
nonIdlePendingLanes (通常の更新だけに絞る)
│
│ & ~suspendedLanes ← Suspend 中を除外
▼
nonIdleUnblockedLanes (今すぐ処理可能な Lane)
│
│ getHighestPriorityLanes ← 最高優先度だけ取り出す
▼
nextLanes (次に処理する Lane = 出力)
上の図は「何を計算しているか」のデータフローです。次の図は、この計算に分岐(Suspend された場合の pinged 復活、WIP との比較)が加わった制御フローです。
// packages/react-reconciler/src/ReactFiberLane.js(一部抜粋、主要部分)
export function getNextLanes(root, wipLanes, rootHasPendingCommit) {
const pendingLanes = root.pendingLanes;
if (pendingLanes === NoLanes) return NoLanes;
const suspendedLanes = root.suspendedLanes;
const pingedLanes = root.pingedLanes;
// ① 非Idle かつ ② Suspend されていない Lane を取り出す
const nonIdlePendingLanes = pendingLanes & NonIdleLanes;
const nonIdleUnblockedLanes = nonIdlePendingLanes & ~suspendedLanes;
if (nonIdleUnblockedLanes !== NoLanes) {
nextLanes = getHighestPriorityLanes(nonIdleUnblockedLanes);
} else {
// 全て suspended → pinged されたものがあれば選択
const nonIdlePingedLanes = nonIdlePendingLanes & pingedLanes;
if (nonIdlePingedLanes !== NoLanes) {
nextLanes = getHighestPriorityLanes(nonIdlePingedLanes);
}
}
// ③ WIP より低優先度なら中断しない
if (wipLanes !== NoLanes && wipLanes !== nextLanes) {
const nextLane = getHighestPriorityLane(nextLanes);
const wipLane = getHighestPriorityLane(wipLanes);
if (nextLane >= wipLane) return wipLanes;
}
return nextLanes;
}
なぜ WIP を奪えないのか: WIP(作業中)より低優先度の Lane が来ても、WIP は中断されません。途中まで進んだ高優先度の作業を捨てて、代わりに低優先度の作業を新規開始するのは本末転倒だからです。せっかく50%進んだ処理を破棄したら、全体の処理時間も伸び、しかも優先度が高い方を後回しにしてしまいます。逆に、新しく来た Lane が WIP より高い優先度なら、途中の作業を捨ててでも切り替える価値があります(ユーザーのクリックなど、即時反映が求められるため)。
同期 vs 時間分割の分岐
getNextLanes で選ばれた Lane が同期実行すべきか、中断可能な時間分割にすべきかを判定するのが includesBlockingLane です。
前提として「同期実行」と「時間分割」の違いを押さえておきます。
| 方式 | 動作 | 用途 |
|---|---|---|
| 同期実行(synchronous rendering) | レンダー処理を開始したら最後まで止まらず一気に完走する。途中でブラウザに制御が戻らないので、ユーザー操作やアニメーションは処理中ブロックされる | クリック直後など、即座に反映したい場合 |
| 時間分割(time slicing) | レンダー処理を 5ms ごとに区切って、区切り目でブラウザに制御を返す。ユーザー操作やアニメーションが途中で割り込めるので画面が固まらない | 検索結果の再描画など、大きな更新をスムーズに見せたい場合 |
ユーザー体験の違い — 何が変わるのか
抽象的な説明だけだとピンと来ないので、具体的な体感差を比べます。
| 場面 | SyncLane(同期実行) | TransitionLane(時間分割) |
|---|---|---|
| ボタンクリック時 | クリック直後に結果が即反映される。処理が重いとその瞬間だけスクロール・アニメーションが一瞬カクつく | (そもそもクリックは SyncLane になる) |
| 検索結果の絞り込み(重い処理) | (仮にこうなったら) 入力するたびに UI が固まり、タイピングが遅延する | 結果表示は数十 ms 遅れるが、タイピング・スクロール・アニメーションは途切れず滑らか |
| スクロール中の更新 | スクロールがカクつく可能性あり | スクロールが滑らかなまま、内容が追従して更新される |
仮にボタンクリックが TransitionLane で処理されたら?
反応が遅く見え、ユーザーは「効いていない?」と二度押ししてしまいます。**「即時性が必要な操作 → SyncLane」「滑らかさが必要な更新 → TransitionLane」**という割り当ては、ユーザーが「何を期待するか」から逆算された設計です。
// packages/react-reconciler/src/ReactFiberLane.js(一部抜粋)
export function includesBlockingLane(lanes) {
const SyncDefaultLanes =
SyncHydrationLane | SyncLane |
InputContinuousHydrationLane | InputContinuousLane |
DefaultHydrationLane | DefaultLane |
GestureLane;
return (lanes & SyncDefaultLanes) !== NoLanes;
}
SyncLane(クリック等)や InputContinuousLane(スクロール等)を含む場合は blocking と判定され、同期レンダリングになります。TransitionLane のような低優先度の Lane だけなら、次のセクションで説明する中断可能なレンダリングになります。
中断・再開の仕組み — workLoopConcurrent を追う
Lane の選択ロジックが「何を処理するか」を決めるのに対し、中断・再開は「いつ処理を止めていつ再開するか」を決めます。この 2 つが連携して Concurrent Rendering が成立します。
まず全体像 — コードに入る前に
細かいコードに入る前に、「中断・再開が実際にどう動くか」を時系列の図で掴んでおきます。
時刻 →
[t=0ms] setState 呼び出し
│ lane を決定(例: TransitionLane)
▼
[t=0ms] Scheduler にタスク登録
│
▼
[t=1ms] performWorkOnRoot 開始
│ includesBlockingLane? → No(TransitionLane は非blocking)
│ → workLoopConcurrent へ
▼
[t=1ms] workLoopConcurrent 実行開始
│ Fiber ツリーを1ノードずつ処理
│ node1 → node2 → node3 → ...
│
[t=6ms] shouldYield() が true を返す(5ms 経過)
│ workInProgress = node20 の状態で中断
│ ★ workInProgress はグローバル変数なので値が残る
▼
[t=6ms] ブラウザに制御を返す
│ ブラウザがクリック/スクロール等を処理
│
[t=20ms] Scheduler から再度 performWorkOnRoot が呼ばれる
│ renderRootConcurrent 内で
│ 「前回と同じ root/lanes か?」をチェック
│ → 同じ → prepareFreshStack をスキップ
│ → workInProgress(= node20)から処理を再開
▼
[t=20ms] workLoopConcurrent 再開(node20 から)
│ node20 → node21 → ...
│
[t=25ms] 全ノード処理完了(workInProgress === null)
│
▼
[t=25ms] commitRoot → DOM 反映
ポイントは 「中断時点の位置(workInProgress)をグローバル変数に残しておく」 こと。これにより、次に呼ばれたときに「続きから」再開できます。以下、この仕組みを支える3つの条件をコードレベルで見ていきます。
中断・再開が成立する3つの条件
Concurrent Rendering の中断・再開は、以下の3つの仕組みの組み合わせで成り立っています。どれか1つが欠けても成立しません。
-
分割可能性(Fiber アーキテクチャ) — 各コンポーネントの処理を Fiber ノード単位に分割し、
whileループで1ノードずつ処理する。ループなので任意のノード間で break できる - 状態保持(workInProgress というグローバル変数) — 次に処理すべき Fiber ノードをモジュールスコープのグローバル変数で保持する。ループを抜けても値が消えない
-
再開判定(root/lanes の同一性チェック) —
renderRootConcurrentで「前回と同じ root・同じ lanes か」を確認し、同じなら初期化(prepareFreshStack)をスキップして続きから再開する
旧 Stack Reconciler で止められなかった理由
旧 Stack Reconciler は JavaScript の関数再帰でツリーを辿っていました。JavaScript には再帰呼び出しのコールスタックを途中で保存・復元する手段がないため、途中で中断すると再開不能になります。Fiber への移行で「再帰」を「ループ + 明示的な状態保持」に置き換えたことで、中断・再開が初めて可能になりました。
以下、3つの条件がコード上でどう実現されているかを順に見ていきます。
ステップ 1: 同期 or 時間分割の判定
前セクションの includesBlockingLane がここで使われます。
// packages/react-reconciler/src/ReactFiberWorkLoop.js(一部抜粋)
const shouldTimeSlice =
(!forceSync &&
!includesBlockingLane(lanes) &&
!includesExpiredLane(root, lanes));
// ※ 実際のソースコードでは checkIfRootIsPrerendering による
// プリレンダリング分岐もあるが、本記事では省略
let exitStatus = shouldTimeSlice
? renderRootConcurrent(root, lanes) // 中断可能
: renderRootSync(root, lanes, true); // 中断不可
コード中に登場する見慣れない変数・関数を整理します。
| 名前 | 何者か |
|---|---|
forceSync |
performWorkOnRoot の引数フラグ。flushSync() 等で明示的に同期レンダーを要求されたときに true になる。true なら時間分割を許さない |
includesBlockingLane(lanes) |
前セクションで登場した関数。SyncLane / InputContinuousLane / DefaultLane 等の「ブロッキング(= 同期すべき)」Lane が含まれるかを返す |
includesExpiredLane(root, lanes) |
飢餓防止で expiredLanes に昇格した Lane が含まれるかを返す。昇格済み Lane があれば強制的に同期で消化する |
checkIfRootIsPrerendering |
プリレンダリング中かどうかの判定。本記事ではスコープ外のため省略したが、真なら時間分割に分岐する場合もある |
renderRootConcurrent / renderRootSync
|
それぞれ中断可能/中断不可のレンダー関数。ステップ 2 以降で中身を追う |
式全体としては、shouldTimeSlice が true になるのは「明示的な同期要求なし かつ ブロッキング Lane を含まない かつ 期限切れ Lane を含まない」の 3 条件すべてを満たす場合だけ。どれか 1 つでも該当すれば同期レンダーに倒す、という防御的な設計です。
TransitionLane など blocking でない Lane だけの場合、shouldTimeSlice = true となり renderRootConcurrent が呼ばれます。
ステップ 2: 中断可能なワークループ(条件①分割可能性 + ②状態保持)
// packages/react-reconciler/src/ReactFiberWorkLoop.js(一部抜粋)
function workLoopConcurrent() {
while (workInProgress !== null && !shouldYield()) {
performUnitOfWork(workInProgress);
}
}
ここで条件①(分割可能性)と②(状態保持)が同時に実現されています。
-
条件①:
whileループなので、shouldYield()がtrueになった時点で即座に break できる。これは再帰では不可能 -
条件②:
workInProgressはモジュールスコープのグローバル変数。ループを抜けても値が生き残り、次回呼び出しでそのまま使える
shouldYield() は Scheduler パッケージが提供する関数で、5ms のタイムスライスを超えたら true を返します。5ms は 120fps(1フレーム約8.3ms)のデバイスでもフレーム内にブラウザへ制御を返せるよう設定されています。
比較として、同期版のループを見ると違いが明確です:
// 同期版: shouldYield チェックなし — 完了まで止まらない
function workLoopSync() {
while (workInProgress !== null) {
performUnitOfWork(workInProgress);
}
}
ステップ 3: 再開の仕組み(条件③同一性チェック)
// packages/react-reconciler/src/ReactFiberWorkLoop.js(一部抜粋)
function renderRootConcurrent(root, lanes) {
// root や lanes が変わった場合だけ新しいスタックを用意する
if (workInProgressRoot !== root || workInProgressRootRenderLanes !== lanes) {
prepareFreshStack(root, lanes); // 新規開始
}
// → 同じ root/lanes なら workInProgress を再利用して「再開」
workLoopConcurrent();
}
用語:prepareFreshStack ─
workInProgressツリーのルートを作成し、レンダーに必要なグローバル変数(workInProgress,workInProgressRoot,workInProgressRootRenderLanes等)を初期化する関数。新規レンダーの場合のみ呼ばれる。
再開のポイントは「workInProgressRoot と workInProgressRootRenderLanes が前回と同じなら、prepareFreshStack をスキップする」ことです。つまり、中断した Fiber からそのまま処理が再開されます。
これが条件③(同一性チェック)にあたります。3つの条件が揃って初めて中断・再開が成立することが、ここまででコードレベルで確認できます。
全体フロー
setState(value)
│
▼
dispatchSetState → lane を決定
│
▼
getNextLanes → 次に処理すべき Lane を選択
│
▼
performWorkOnRoot
│
├─ includesBlockingLane? → Yes → workLoopSync(中断なし)
│
└─ No → workLoopConcurrent
│
├─ shouldYield() = true(5ms 経過)
│ → 中断(workInProgress を保持)
│ → 次のスケジュールで再開
│
└─ 完了 → commitRoot → DOM 更新
useState との接点 — dispatchSetState の lane
ここまでの知識を使って、useState の内部で lane がどう使われるかを見ていきます。
lane の割り当て — 呼び出しコンテキストが優先度を決める
同じ setState でも、requestUpdateLane が「呼び出し時の状況」を読んで Lane を切り替えます。以下のコードで、lane がどう決まるか見ていきます。
// packages/react-reconciler/src/ReactFiberHooks.js(一部抜粋)
function dispatchSetState(fiber, queue, action) {
const lane = requestUpdateLane(fiber);
const update = {
lane, // この更新の優先度
action,
hasEagerState: false,
eagerState: null,
next: null,
};
// ※ 実際のソースコードには revertLane(useTransition 関連)や
// gesture フィールドも存在するが、本記事のスコープ外のため省略
// queue に追加 → スケジューリング
}
requestUpdateLane は呼び出し時の状況によって異なる Lane を返します。どこで呼ばれたかに注目して見比べてみます。
// ──────────────────────────────────────────
// ケース1: DiscreteEvent(click等)のハンドラ内
// → SyncLane(最高優先度、同期レンダリング)
// ──────────────────────────────────────────
button.onclick = () => {
setCount(1); // ★ SyncLane が割り当てられる
};
// ──────────────────────────────────────────
// ケース2: startTransition 内
// → TransitionLane(14本のうち1本、中断可能)
// ──────────────────────────────────────────
startTransition(() => {
setSearchQuery(input); // ★ TransitionLane が割り当てられる
});
// ──────────────────────────────────────────
// ケース3: scroll 等の ContinuousEvent ハンドラ内
// → InputContinuousLane
// ──────────────────────────────────────────
window.onscroll = () => {
setScrollY(window.scrollY); // ★ InputContinuousLane が割り当てられる
};
同じ setCount(1) という書き方でも、実行時のコンテキスト(イベントハンドラ内か、startTransition 内か)によって優先度(Lane)が変わるのです。
低優先度更新のスキップ — baseState と baseQueue
レンダー時、updateReducer は queue に積まれた更新を順番に処理します。
ここで注目すべきは、現在の renderLanes に含まれない更新はスキップされるという点です。
// packages/react-reconciler/src/ReactFiberHooks.js(一部抜粋)
// ※ Offscreen 関連の分岐は省略
// renderLanes に含まれない更新はスキップ
const shouldSkipUpdate = !isSubsetOfLanes(renderLanes, updateLane);
if (shouldSkipUpdate) {
// スキップ: baseQueue に追加して次回レンダーに持ち越し
const clone = { lane: updateLane, action: update.action, ... };
if (newBaseQueueLast === null) {
newBaseQueueFirst = newBaseQueueLast = clone;
newBaseState = newState; // ★ スキップ開始時点の state を保存
} else {
newBaseQueueLast = newBaseQueueLast.next = clone;
}
} else {
// 処理: reducer を適用
newState = reducer(newState, update.action);
}
ここで登場した baseState と baseQueue の役割は次のとおりです。
| 名前 | 役割 |
|---|---|
baseState |
スキップが発生した時点までの計算済み state。次回レンダーで低優先度更新を処理する際の「出発点」になる |
baseQueue |
今回スキップされた更新のリスト。次回レンダーでこの queue から再処理される |
なぜ 2 つ必要なのか — 数字で追ってみる
ここで一つ、自然な疑問が出てきます。「update1〜3 を全部計算してから一気に表示すればいいのでは? それなら memoizedState だけで済むはず」— 筆者も最初はそう考えました。ですがソースを読み進めると、この疑問こそ Concurrent Rendering 設計の核心に触れていることがわかります。順に整理していきます。
なぜ「全部計算してから表示」ではダメなのか
低優先度の update2 は「計算できない」のではなく、**「いま反映すべきでない」**と React が判断したものです。典型的なシナリオで考えてみます。
ユーザーが検索ボックスに文字を入力中
update1 (SyncLane) : 入力欄の文字表示 (+1)
update2 (TransitionLane): 検索結果リストの重い再計算 (+10) ← startTransition で囲った
update3 (SyncLane) : もう1文字打って入力欄が更新 (+100)
startTransition の目的は「検索結果の計算は遅れてもいいから、入力欄だけは即座に反映してほしい」というユーザー体験の確保にあります。仮に「全部計算してから表示」するとどうなるか:
文字を打つ → 検索結果(重い)の計算完了を待つ → やっと入力欄に文字が出る
↑
この間、入力欄がフリーズして見える → startTransition の意味が消える
そこで 1 回目のレンダーでは SyncLane の update1・update3 だけを適用した memoizedState = 101 を即座に画面に出すのが React の戦略です。update2 は 2 回目のレンダーに持ち越されます。
2 回目のレンダーで「出発点」が必要になる
ここで baseState と baseQueue の両方が必要になる理由が見えてきます。1 回目で memoizedState = 101 を表示したあと、2 回目で update2 を反映するには「update2 を挟んで再計算」することになります。このとき memoizedState = 101 はすでに update1 と update3 が適用済みの汚染された値なので、ここから再計算を始めると二重適用が起きてしまう — という仕組みです。
具体的な数字で追いかけてみます。
初期状態: count = 0
以下の順で setState が呼ばれた:
update1: count + 1 (SyncLane, 高優先度)
update2: count + 10 (TransitionLane, 低優先度) ← startTransition 内
update3: count + 100 (SyncLane, 高優先度)
【1回目のレンダー】 renderLanes = SyncLane のみ
処理順に queue を走査:
update1 → 処理: newState = 0 + 1 = 1
update2 → スキップ(TransitionLane は renderLanes に非含)
│ ★ この時点で baseState を「スキップ直前の値 = 1」で保存
│ ★ update2 を baseQueue に追加
update3 → 処理: newState = 1 + 100 = 101
│ ★ ただし update2 が既にスキップされているので
│ update3 も baseQueue にコピー(一貫性保持のため)
結果:
memoizedState = 101 (画面に表示される値)
baseState = 1 (次回の出発点)
baseQueue = [update2(+10), update3(+100)]
【2回目のレンダー】 renderLanes = TransitionLane を含む
baseState = 1 から開始し、baseQueue を走査:
update2 → 処理: newState = 1 + 10 = 11
update3 → 処理: newState = 11 + 100 = 111
結果: memoizedState = 111 ← 最終的に正しい値
ポイント: もし baseState を持たず、memoizedState(= 101)から再計算してしまうと、101 + 10 + 100 = 211 となり、update1 が二重に適用されてしまいます。baseState が「update1 まで適用済みの状態」を保存しているからこそ、次回 1 + 10 + 100 = 111 と正しく再計算できるのです。
baseQueue に update3 までコピーされる理由も同じです。次回レンダー時に update2 から再計算する際、その後に続く更新も必要になるため、スキップ開始以降の全更新を baseQueue に持ち越す必要があります。
具体例: 優先度による処理の分岐
queue に 3 つの更新がある場合:
update1 (SyncLane) → renderLanes に含まれる → 処理する
update2 (TransitionLane) → renderLanes に含まれない → スキップ
update3 (SyncLane) → renderLanes に含まれる → 処理する
(ただし update2 以降は baseQueue にも追加)
結果:
memoizedState = update1 + update3 を適用した値(表示用)
baseState = update1 まで適用した値(次回の出発点)
baseQueue = [update2, update3](次回再処理)
ここで重要なのは、一度スキップが始まるとそれ以降の全更新が baseQueue に追加される点です。update3 は今回処理されますが、次回 update2 から再計算する際にも必要なため、baseQueue にコピーされます。これにより、最終的な state の一貫性が保証されます。
まとめ
Lanes の仕組みを一言で表すなら:
「32 ビットのビットマスクで更新の優先度を管理し、ビット演算で高速に合成・判定・選択を行う仕組み」
全体の流れを、各ステップの役割とともに振り返ります:
setState(value) を呼び出す
│
▼ 【ステップ①:優先度の決定】
│ requestUpdateLane が呼び出しコンテキスト(click? transition?)を読み、
│ 適切な Lane を割り当てる
│
▼ 【ステップ②:次の処理対象を選ぶ】
│ getNextLanes が pendingLanes から Suspended を除外し、
│ 最高優先度の Lane を選ぶ。ただし WIP より低ければ WIP を続行
│
▼ 【ステップ③:実行方式を決める】
│ includesBlockingLane が「同期か時間分割か」を判定
│ SyncLane を含む → 同期(workLoopSync、中断なし)
│ TransitionLane のみ → 中断可能(workLoopConcurrent)
│
▼ 【ステップ④:中断・再開しつつレンダー】
│ workLoopConcurrent が 5ms ごとにブラウザへ制御を返し、
│ workInProgress(グローバル変数)で現在地を保持して再開可能にする
│
▼ 【ステップ⑤:低優先度更新の持ち越し】
│ renderLanes に含まれない更新は baseQueue へ、
│ baseState に出発点を保存して次回レンダーで再処理
│ 例: count=0 に対して +1(Sync) / +10(Transition) / +100(Sync) が来た場合、
│ 1回目: memoizedState=101, baseState=1, baseQueue=[+10, +100]
│ 2回目: 1 + 10 + 100 = 111(正しい最終値)
│ ★ baseState がないと 101 から再計算して update1 が二重適用される
│
▼ 【ステップ⑥:コミット】
│ commitRoot → DOM 更新
各ステップが前のステップの出力を入力にして動くことで、Concurrent Rendering の「優先度付け → 選択 → 中断可能な実行 → 持ち越し」という流れが成立しています。
この記事で触れなかったこと:
-
requestUpdateLaneの内部実装(startTransitionがどう Lane を切り替えるか) - Entanglement(複数の更新を同一バッチに強制する仕組み)
- Scheduler パッケージの詳細(タスクキューの管理)
これらは今後の記事で掘り下げていく予定です。
参考
- React GitHub — ReactFiberLane.js: Lane 定数・ビット演算関数・getNextLanes の実装
- React GitHub — ReactFiberWorkLoop.js: workLoopConcurrent・performWorkOnRoot の実装
- React GitHub — ReactFiberHooks.js: dispatchSetState・updateReducerImpl の実装
- Initial Lanes implementation (PR #18796): Andrew Clark による Lanes 設計の初期実装と設計意図
- React v18.0 リリースブログ: Concurrent Rendering の公式定義
- React 18 WG — Concurrent Scheduling Discussion #27: acdlite による Lane 設計の補足説明
