1
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 の内部構造をソースコードから理解する【mountState / updateState 編】

1
Posted at

本記事は、React の内部実装を理解するための学習ログです。
前回の共通処理編に続き、今回は mountStateupdateState の中身を読んでいきます。


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

前回は、useState が呼ばれてから mountState / updateState に振り分けられるまでの共通処理を読みました。

今回はその続き、③以降の実際の処理を読んでいきます。

renderWithHooks()
  ↓
Dispatcher をセット
  ↓
コンポーネント関数を実行

【コンポーネント内】

useState(10) が呼ばれる
  ↓
① Dispatcher の解決         ← 前回
  ↓
② mountState / updateState への振り分け  ← 前回
  ↓
③ Hook ノードの生成          ← 今回(mountState)
  ↓
④ Fiber への紐付け           ← 今回(mountState)
  ↓
⑤ queue の初期化            ← 今回(mountState)
  ↓
⑥ [state, dispatch] を返す  ← 今回(両方)

前提知識:Fiber と Dispatcher について

前回記事で詳しく説明しましたが、要点だけ再掲します。

Fiber とは

React は各コンポーネントの情報を Fiber(ファイバー) と呼ばれる内部オブジェクトで管理しています。

// Fiber オブジェクトのイメージ(簡略版)
{
  type: MyComponent,      // どのコンポーネントか
  memoizedState: ...,     // このコンポーネントの Hook リスト
  alternate: ...,         // 前回レンダー時の Fiber への参照
  ...
}

React は画面に表示されている状態(current Fiber)と、次のレンダーで構築中の状態(workInProgress Fiber)の2本の Fiber ツリーを持ち、レンダーが完了すると workInProgress が current に切り替わります。

useState で保存した state の値も、この Fiber オブジェクトの中に格納されています。

Dispatcher とは

useState を呼んだとき、React は Dispatcher と呼ばれるオブジェクトを経由して実際の処理を呼び出しています。Dispatcher はいわば「今どのフェーズにいるか」によって差し替わる関数の束です。

// 初回レンダー時
HooksDispatcherOnMount = {
  useState: mountState,
  useEffect: mountEffect,
  ...
}

// 再レンダー時
HooksDispatcherOnUpdate = {
  useState: updateState,
  useEffect: updateEffect,
  ...
}

初回レンダーなら mountState、再レンダーなら updateState が動く、という仕組みです。

mountState:初回レンダーの処理

まず mountState の実装を全体で見てみましょう。

// packages/react-reconciler/src/ReactFiberHooks.js(一部抜粋)
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];
}

実質的な処理は mountStateImpl に委譲されています。
返り値は [state, dispatch] で、これがそのまま const [count, setCount] = useState(0) の分割代入に対応します。

mountStateImpl:Hook ノードの生成と初期化

mountStateImpl の実装を見てみます。

// packages/react-reconciler/src/ReactFiberHooks.js(一部抜粋)
function mountStateImpl(initialState) {
  const hook = mountWorkInProgressHook(); // ① Hook ノードを生成し、Fiber に紐付ける

  if (typeof initialState === "function") {
    const initialStateInitializer = initialState;
    initialState = initialStateInitializer(); // ② 遅延初期化
  }

  hook.memoizedState = hook.baseState = initialState; // ③ 状態を保存

  // ④ queue を初期化
  const queue = {
    pending: null,
    lanes: NoLanes,
    dispatch: null,
    lastRenderedReducer: basicStateReducer,
    lastRenderedState: initialState,
  };
  hook.queue = queue;

  return hook;
}

処理は大きく4つです。順に見ていきます。

① mountWorkInProgressHook:Hook ノードの生成と Fiber への紐付け

この関数は「初回レンダーで Hook ノードを新しく作り、Fiber に登録する」関数です。
コンポーネントが初めてレンダーされるとき、useState が呼ばれるたびにこの関数が実行されます。Hook ノード(state の値などを保持するオブジェクト)をゼロから生成し、Fiber のリンクリストの末尾に繋いでいくのが役割です。

useState(0) を呼ぶ → mountWorkInProgressHook()
  ↓
  Hook オブジェクトを new する
  { memoizedState: null, queue: null, next: null, ... }
  ↓
  Fiber にすでに Hook があるか?
    ┌─ No(最初の Hook)→ Fiber.memoizedState = newHook
    └─ Yes(2つ目以降)→ 前の hook.next = newHook(末尾に追加)
  ↓
  作った hook を返す
// packages/react-reconciler/src/ReactFiberHooks.js(一部抜粋)
function mountWorkInProgressHook() {
  const hook = {
    memoizedState: null, // 現在の state 値
    baseState: null, // 優先度処理のベースとなる state
    baseQueue: null, // 未処理の update のキュー
    queue: null, // dispatch された update の管理オブジェクト
    next: null, // 次の Hook へのポインタ(リンクリスト)
  };

  if (workInProgressHook === null) {
    // このコンポーネントで最初の Hook
    currentlyRenderingFiber.memoizedState = workInProgressHook = hook;
  } else {
    // 2つ目以降の Hook:リンクリストの末尾に追加
    workInProgressHook = workInProgressHook.next = hook;
  }

  return workInProgressHook;
}

Hook は1つ1つが普通の JavaScript オブジェクトです。

ここで使われている連鎖代入(a = b = c)のパターンを補足します。JavaScript の = は右から左に評価されるため、それぞれ以下のように動きます。

// 最初の Hook の場合
currentlyRenderingFiber.memoizedState = workInProgressHook = hook;
// ① workInProgressHook = hook   (作業ポインタを新しい hook に設定)
// ② currentlyRenderingFiber.memoizedState = hook (Fiber の先頭にも同じ hook を登録)

// 2つ目以降の Hook の場合
workInProgressHook = workInProgressHook.next = hook;
// ① workInProgressHook.next = hook  (前の Hook の next に新しい hook を繋ぐ)
// ② workInProgressHook = hook       (作業ポインタを新しい hook に進める)

どちらも「右から左への連鎖代入」という同じパターンですが、最初の Hook では Fiber への登録が必要なため左辺に currentlyRenderingFiber.memoizedState があり、2つ目以降では リスト末尾への追加が必要なため左辺に workInProgressHook.next がある、という違いです。

用語:memoizedState(メモ化ステート)
「メモ化」とは計算結果を保持しておくことを指します。hook.memoizedState はそのレンダー時点での state の値を保持しているフィールドです。Fiber にも同名のフィールドがあり、そちらは「この Fiber が持つ Hook リストの先頭」を指しています(同じ名前ですが指すものが異なります)。

最初の Hook であれば Fiber の memoizedState に直接セットし、2つ目以降は前の Hook の next に繋げて リンクリスト(各要素が「次の要素」へのポインタを持つデータ構造)を構成します。

useState を2回呼んだ場合、構造はこうなります。


Fiber (workInProgress)
└─ memoizedState
        ↓
      Hook1 { memoizedState: 0, next: → }
                                       ↓
      Hook2 { memoizedState: "", next: null }

ポイント:これが「Hook の呼び出し順序を変えてはいけない」ルールの根拠です。
React はインデックスではなく順番で対応する Hook を特定しているためです。

② 遅延初期化(Lazy Initialization)

if (typeof initialState === "function") {
  const initialStateInitializer = initialState;
  initialState = initialStateInitializer();
}

useState(() => expensiveComputation()) の形式では、関数は初回レンダーでだけ実行されます。
mountStateImpl(初回専用)の中だけで initialState() を呼んでおり、updateState 側には同等のコードがないため、再レンダー時には一切呼ばれません。

ポイント:初期値の計算コストが高い場合は、値を直接渡すのではなく関数を渡す形式を使う。

③ 状態の保存

hook.memoizedState = hook.baseState = initialState;

memoizedStatebaseState の両方に初期値をセットします。

用語:baseState(ベースステート)
baseState は Concurrent Mode(処理を優先度に応じて中断・再開できるモード)での優先度制御に使われる値です。優先度の低い update が後回しにされたとき、どこから再計算を始めるかの起点になります。

通常の使い方では baseState を意識する必要はありませんが、Concurrent Mode での優先度制御のために memoizedState と分けて管理されています。

④ queue の初期化

const queue = {
  pending: null, // dispatch された未処理の update(循環リスト)
  lanes: NoLanes, // この queue に紐づく優先度情報
  dispatch: null, // setState 関数(後でセットされる)
  lastRenderedReducer: basicStateReducer, // 最後のレンダーで使った reducer
  lastRenderedState: initialState, // 最後のレンダーの state 値
};
hook.queue = queue;

用語:queue(キュー)
setCount(5) を複数回呼ぶと、update は即座に反映されるのではなく一度ここに蓄積されます。React はレンダーのタイミングで queue に積まれた update をまとめて処理します。

用語:lanes(レーン)
React が update の優先度を管理するための仕組みです。「緊急な更新」と「後回しにできる更新」を区別するために使われます。通常の useState では深く意識する必要はありません。

lastRenderedReducer に入っている basicStateReducer は、useState が内部的に使っている reducer です。

function basicStateReducer(state, action) {
  return typeof action === "function" ? action(state) : action;
}

この actionsetState に渡した引数のことです。setCount(5) なら action5setCount(prev => prev + 1) なら actionprev => prev + 1 という関数になります。

つまり、値を渡した場合はそのまま次の state になり、関数を渡した場合は現在の state を引数に呼び出した結果が次の state になります。
useStateuseReducer の特殊ケースであることが、ここから読み取れます。

dispatch の生成と bind

mountStateImpl が返した hook を使って、mountState が dispatch 関数を作ります。

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];
}

dispatchSetStatecurrentlyRenderingFiber(現在処理中の Fiber)と queue を束縛(bind)することで、
setCount を呼ぶだけで「どの Fiber のどの queue への更新か」が特定できるようになっています。

setCount(5) を呼ぶ
  ↓
dispatchSetState(currentlyRenderingFiber, queue, 5) が実行される
                 ↑ bind 時に束縛済み

queue.dispatch = dispatch で、作った dispatch 関数を queue にも保存しています。これは再レンダー時(updateReducerImpl)に queue.dispatch から dispatch を取り出して返すためです。初回レンダーで bind した関数をそのまま使い回すことで、再レンダーのたびに新しい関数を作り直す必要がなくなります。

最後の return [hook.memoizedState, dispatch] が、コンポーネント側で const [count, setCount] = useState(0) として受け取る配列そのものです。

updateState:再レンダーの処理

次に、再レンダーで呼ばれる updateState を見てみます。

// packages/react-reconciler/src/ReactFiberHooks.js(一部抜粋)
function updateState(initialState) {
  return updateReducer(basicStateReducer, initialState);
}

mountState と比べて驚くほど薄いです。
updateStateupdateReducer を呼ぶだけで、実体はすべて updateReducer 側にあります。

先述の通り useState は内部的に basicStateReducer を使った useReducer として実装されており、ここでもその構造が現れています。

updateReducer:update の適用

updateReducer の実装を見てみます。

// packages/react-reconciler/src/ReactFiberHooks.js(一部抜粋)
function updateReducer(reducer, initialArg, init) {
  const hook = updateWorkInProgressHook(); // ① 対応する Hook ノードを取得
  return updateReducerImpl(hook, currentHook, reducer);
}

まず updateWorkInProgressHook で、前回レンダー時の Hook ノード(current 側)を参照しながら、今回のレンダー用の Hook ノードを用意します。

updateWorkInProgressHook:前回の Hook を引き継ぐ

この関数は「再レンダーで、前回レンダー時に作った Hook ノードを順番に辿り、今回のレンダー用の Hook ノードを用意する」関数です。

再レンダーのときは Hook ノードをゼロから作るのではなく、前回レンダー時(current Fiber)に作ったリンクリストを先頭から1つずつ辿っていきます。useState が呼ばれるたびに「次の Hook」へポインタを進め、対応するノードを返します。これにより「前回と今回で同じ順番の useState には同じ Hook ノードが対応する」という対応付けが成立します。

再レンダー開始
↓
currentHook は null(リスト未走査)
↓
current Fiber の memoizedState(リストの先頭)から開始
↓
useState() が呼ばれるたびに currentHook.next へ進む
↓
current 側の Hook を参照しながら
workInProgress 側の Hook ノードを構築して返す

呼び出し回数:1回目 → Hook1、2回目 → Hook2、…
// packages/react-reconciler/src/ReactFiberHooks.js(一部抜粋)
function updateWorkInProgressHook() {
  // current(前回の Fiber)側の Hook を順番に辿る
  let nextCurrentHook;
  if (currentHook === null) {
    const current = currentlyRenderingFiber.alternate;
    if (current !== null) {
      nextCurrentHook = current.memoizedState; // リンクリストの先頭から
    } else {
      nextCurrentHook = null;
    }
  } else {
    nextCurrentHook = currentHook.next; // 次の Hook へ
  }

  // workInProgress 側も同様に辿る
  // ...(新しい Hook ノードを current からコピーして返す)
}

用語:alternate(オルタネート)
Fiber が持つ alternate フィールドは、もう一方の Fiber ツリーへの参照です。workInProgress Fiber から見ると alternate は current Fiber を指しており、これを辿ることで前回のレンダー結果にアクセスできます。

mountWorkInProgressHook が毎回新しい Hook ノードを作ったのに対し、updateWorkInProgressHook前回の Fiber(current)に保存されている Hook を順番に辿り、対応するノードを参照します。

前回レンダー (current Fiber)
 └─ memoizedState
        ↓
      Hook1 { memoizedState: 0 } → Hook2 { memoizedState: "" }
        ↑                                   ↑
    1回目の useState()               2回目の useState()
    呼び出し時に参照                 呼び出し時に参照

今回レンダー (workInProgress Fiber)
 └─ memoizedState
        ↓
      Hook1 { ... }  ← current の Hook1 を参照して構築

updateReducerImpl:update を適用して新しい state を計算する

この関数が再レンダー処理の核心部分です。setCount(5)setCount(prev => prev + 1) によって積まれた update を取り出し、basicStateReducer を適用して新しい state を計算します。

// packages/react-reconciler/src/ReactFiberHooks.js(重要部分を抜粋)
function updateReducerImpl(hook, current, reducer) {
  const queue = hook.queue;
  queue.lastRenderedReducer = reducer;

  let baseQueue = hook.baseQueue;
  const pendingQueue = queue.pending;

  if (pendingQueue !== null) {
    // pending と baseQueue を統合する(循環リストの結合)
    if (baseQueue !== null) {
      const baseFirst = baseQueue.next;
      const pendingFirst = pendingQueue.next;
      baseQueue.next = pendingFirst;
      pendingQueue.next = baseFirst;
    }
    current.baseQueue = baseQueue = pendingQueue;
    queue.pending = null;
  }

  const baseState = hook.baseState;

  if (baseQueue === null) {
    // 未処理の update がなければ baseState をそのまま使う
    hook.memoizedState = baseState;
  } else {
    // update を順番に適用して新しい state を計算する
    const first = baseQueue.next;
    let newState = baseState;
    let update = first;

    do {
      // 優先度(lanes)の判定により、この update を適用するかスキップするか決める
      // ...(省略:優先度が足りない場合は baseQueue に残して後回し)

      // update を適用する
      const action = update.action;
      if (update.hasEagerState) {
        // eager state(事前計算済みの state)があればそれを使う
        newState = update.eagerState;
      } else {
        newState = reducer(newState, action);
      }

      update = update.next;
    } while (update !== null && update !== first);

    hook.memoizedState = newState;
    hook.baseState = newBaseState;
    hook.baseQueue = newBaseQueueLast;
    queue.lastRenderedState = newState;
  }

  const dispatch = queue.dispatch;
  return [hook.memoizedState, dispatch];
}

処理の流れをもう少し具体的に追ってみます。

まず queue.pending に蓄積された update を baseQueue に統合します。pendingbaseQueue もどちらも循環リストになっており、両方のリストの末尾と先頭を繋ぎ替えることで1本の循環リストにマージします。統合後 queue.pendingnull にリセットされます。

次に、baseQueue に入っている update を先頭(baseQueue.next)から do-while ループで順番に処理していきます。各 update には actionsetState に渡された値または関数)が入っており、これを reduceruseState の場合は basicStateReducer)に渡して新しい state を算出します。

なお、hasEagerState は最適化のためのフラグです。dispatchSetState(次回の記事で詳しく扱います)の時点で state を事前計算できた場合、reducer を再度呼ばずにその結果をそのまま使います。

具体例で見てみましょう。以下のように setState を3回呼んだ場合を考えます。

// 現在の state: 0
setCount(1); // update1: action = 1
setCount(2); // update2: action = 2
setCount((prev) => prev + 10); // update3: action = (prev) => prev + 10

これらの update が queue に積まれた状態で再レンダーが走ると、updateReducerImpl は以下のように処理します。

baseState = 0(起点)

update1: basicStateReducer(0, 1)          → newState = 1
update2: basicStateReducer(1, 2)          → newState = 2
update3: basicStateReducer(2, prev => prev + 10) → newState = 12

hook.memoizedState = 12

basicStateReducer が値ならそのまま返し、関数なら現在の state を引数に実行するため、update の種類(値 / 関数)が混在していても統一的に処理できます。

計算結果を hook.memoizedState に書き込んで [state, dispatch] を返すと、
コンポーネントの再レンダーが新しい state の値で行われます。

まとめ:mountState / updateState の全体像

【初回レンダー】mountState(initialState)
  ↓
mountStateImpl(initialState)
  ↓
  mountWorkInProgressHook()
    → Hook ノードを生成し、Fiber のリンクリストに追加
  ↓
  initialState が関数なら実行(遅延初期化)
  ↓
  hook.memoizedState = hook.baseState = initialState
  ↓
  queue を初期化
    { pending, lanes, dispatch, lastRenderedReducer, lastRenderedState }
  ↓
dispatchSetState.bind(fiber, queue) で dispatch を生成
  ↓
[hook.memoizedState, dispatch] を返す

【再レンダー】updateState(initialState)
  ↓
updateReducer(basicStateReducer, initialState)
  ↓
  updateWorkInProgressHook()
    → current Fiber の Hook リストを辿り、対応ノードを取得
  ↓
  updateReducerImpl(hook, currentHook, reducer)
    → pending な update を順番に reducer で適用
    → hook.memoizedState に新しい state を書き込む
  ↓
[hook.memoizedState, dispatch] を返す

初回と再レンダーで処理は大きく異なりますが、返すものは同じ [state, dispatch] です。
コンポーネントから見ると useState は常に同じインターフェースに見えますが、その裏では Dispatcher の切り替えによって全く別の処理が走っています。

次回予告

次回は dispatchSetState の中身を読んでいきます。

今回、mountStatedispatchSetState.bind(null, currentlyRenderingFiber, queue) により fiber と queue が束縛されるところまでを見ました。次回はその先、実際に setCount(5) が呼ばれたときに action を受け取って何が起きるのかを追います。

  • update オブジェクトがどう生成されるか
  • queue にどう積まれるか
  • 再レンダーがどうスケジュールされるか

を見ていきます。

1
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
1
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?