2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

React useState の内部構造をソースコードから理解する【dispatchSetState 編】

2
Posted at

本記事は、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]                │
     └─────────────────────────────────────────────┘

mountStateupdateState の詳細は前回の記事で解説しました。今回注目するのは、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) が「count1 にしてほしい」という注文伝票を出しているだけだからです。実際に count1 になるのは、React が再レンダーを実行したときです。

この3段階の分離により、React は複数の更新をバッチ処理(まとめて処理)したり、優先度の高い更新を先に処理したりできます。

今回は ❶ 予約フェーズ(dispatchSetState の中身を詳しく読んでいきます。

はじめに:前回のおさらい

前回は、mountStateupdateState の処理を読みました。

  • 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 の実装を全体で見てみましょう。

実際のソースコードでは、dispatchSetStatedispatchSetStateInternal2関数に分離されています。

// 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) と書くと、action11 が入ります。
setCount(prev => prev + 1) と書くと、action にその関数がそのまま入ります。

requestUpdateLane:優先度の決定

const lane = requestUpdateLane(fiber);

React は「この更新はどのくらい急ぎか?」を判断して、優先度(lane)を割り当てます。

用語:lane(レーン)
React は更新の緊急度を「レーン」というビットフラグで管理しています。高速道路の車線(レーン)をイメージしてください。追い越し車線(高優先度)を走る更新は先に処理されます。

lane どんなときに割り当てられるか
SyncLane(最高優先) ボタンクリックなど、単発のユーザー操作 onClick 内の setState
InputContinuousLane スクロールやドラッグなど、連続的な操作 onScroll 内の setState
DefaultLane イベントハンドラの外 setTimeoutfetch().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 を追加できます。
たとえば lanes0b0000000(何もなし)のとき、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 に再レンダーを依頼

この記事のポイント

  1. setState は state を即座に変えない。更新の「予約」を登録するだけ。実際の計算は再レンダー時に行われる
  2. eager state により、state が変わらない場合は再レンダーを丸ごとスキップできる(bailout)
  3. enqueue は2段階で行われる。バッファに貯めてからまとめてリンクリストに連結することで、バッチ処理を実現している
  4. 循環リンクリストは1つのポインタで先頭・末尾の両方に O(1) でアクセスでき、リストの結合も O(1) で行える効率的なデータ構造

次回予告

次回は 優先度(Lanes)と中断・再開 を読んでいく予定です。

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?