Skip to content

6.3 Hook はどう動いているか ​

3.4 useState や 3.6 useEffect で「フックは条件分岐や繰り返しの中で呼んではいけない」というルールを扱いました。この章ではそのルールの理由を、Fiber ノード内部に作られる 連結リスト の観点から覗いていきます。

この章も実装詳細には踏み込まず、ルールが必要な理由 の理解を目的にしています。実装は React のバージョンにより変化します。

Hook はどこに保存されているか ​

関数コンポーネントは、呼ばれるたびにローカル変数が初期化し直されます。それなのに useState で作ったカウンターの値が前回のレンダーを跨いで保持されているのは、state がコンポーネント関数の外 —— 正確には対応する Fiber ノードの中 —— に格納されているから です。

React 公式ドキュメントの表現を借りると、こうなります。

React associates each piece of state it's holding with the correct component by where that component sits in the render tree. — Preserving and Resetting State

6.2 で見た Fiber ノードには memoizedState というフィールドがあり、そのコンポーネントで呼ばれたフックの情報がここに格納されます。複数のフックを呼んだ場合は、連結リスト (linked list) の形でつながります。

React のソース(packages/react-reconciler/src/ReactFiberHooks.js, v19.2.5 タグ時点)には、Hook の型がだいたい次のように定義されています。

ts
// ReactFiberHooks.js より引用(型定義を抜粋)
type Hook = {
  memoizedState: any,
  baseState: any,
  baseQueue: Update<any, any> | null,
  queue: any,
  next: Hook | null,
};

ポイントは最後の next: Hook | null です。各 Hook は「次の Hook」を指すポインタを持っていて、1 本の鎖として繋がります。Fiber ノードの memoizedState は、その鎖の先頭を指す形です。

図 6.3.1 Fiber ノードと Hook の連結リスト ​

FiberNodememoizedStateHook #1useState countmemoizedState: 0next →Hook #2useState namememoizedState: 'a'next →Hook #3useEffectmemoizedState: …next → nullReact は呼び出しのたびに、先頭から next を辿って次の Hook を取り出す

アニメーションしている矢印は、React が連結リストを辿る流れのイメージです。この構造は、コンポーネント内で書いた「フックを呼ぶ順番」そのものを表しています。

呼び出し順序で state を対応付ける ​

React は フックを呼ばれた順番 で、この連結リストを辿ります。初回レンダー時は新しい Hook ノードを作ってリストに追加し、2 回目以降のレンダーでは既存のリストを最初から順にポインタで辿って再利用する、という流れです。

たとえば、次のようなコンポーネントを考えてみましょう。

tsx
function Profile() {
  const [name, setName] = useState('alice');   // 1 個目
  const [age, setAge] = useState(30);          // 2 個目
  useEffect(() => {/* ... */}, [name]);        // 3 個目
  return <div>{name}</div>;
}

2 回目以降の再レンダー時、React は次のように動きます。

  1. 1 個目の useState 呼び出し → リストの先頭 (Hook #1) を取り出して name の state を返す
  2. 2 個目の useState 呼び出し → next を辿って Hook #2 を取り出し age の state を返す
  3. useEffect 呼び出し → さらに next を辿って Hook #3 を取り出す

ここが一番のポイントです。「何番目に呼ばれたか」だけが、Hook と state の対応を決める情報 なのです。変数名 name や age は、React にとって何の手がかりにもなっていません。

useState の擬似実装 ​

雰囲気を掴むために、簡略化した擬似実装を示します。React の実装そのものではなく、発想を理解するためのイラスト くらいの気持ちで眺めてみてください。

js
// 擬似コード: React 内部実装のイメージ
let currentFiber = null;        // レンダー中の Fiber
let workInProgressHook = null;  // 処理中の Hook

function useState(initial) {
  const hook = getOrCreateHook();

  if (isMount()) {
    // 初回レンダー: 初期値を保存
    hook.memoizedState = initial;
    hook.queue = { pending: [] };
  }
  // 更新レンダー時は保存済みの値を使う

  const setState = (value) => {
    hook.queue.pending.push(value);
    scheduleRender(currentFiber);   // 再レンダーを予約
  };

  return [hook.memoizedState, setState];
}

function getOrCreateHook() {
  // 連結リストの「次の Hook」を取り出すか、無ければ新規作成
  if (workInProgressHook === null) {
    // リストの先頭
    workInProgressHook = currentFiber.memoizedState || createHook();
    currentFiber.memoizedState = workInProgressHook;
  } else {
    // 次のノードへ進む
    workInProgressHook.next = workInProgressHook.next || createHook();
    workInProgressHook = workInProgressHook.next;
  }
  return workInProgressHook;
}

要点はこの 3 つです。

  • Hook は呼ばれるたびに「リストの次のノード」を取り出す
  • state 本体は hook.memoizedState に入る
  • setState は Hook への参照をクロージャで掴んでいるので、後から値を書き換えられる

Dispatcher: mount と update で処理が違う ​

React 本体は、同じ useState という名前の関数でも、mount(初回レンダー)と update(再レンダー)で別の実装 を呼び分けています。これを担うのが Dispatcher という内部オブジェクトです。

React uses different hook implementations depending on component state: Mount phase (HooksDispatcherOnMount) for initial render; Update phase (HooksDispatcherOnUpdate) for subsequent renders. — 参考: ReactFiberHooks.js (v19.2.5 タグ)

  • mount 用: Hook ノードを新規作成して、初期値を詰める
  • update 用: 既存の Hook ノードをポインタで辿って再利用する

コンポーネントのレンダーを始める直前に、React は状況(初回レンダーか再レンダーか)に応じて Dispatcher を切り替えます。useState の import 先は常に同じですが、内部では「どちらの段階か」を判断して適切な実装に委譲しているわけですね。

Rules of Hooks が必要な理由 ​

公式の Rules of Hooks は、こう定められています。

Don't call Hooks inside loops, conditions, nested functions, or try/catch/finally blocks. Instead, always use Hooks at the top level of your React function. — Rules of Hooks

連結リストの仕組みを踏まえると、このルールの理由が一気にクリアになります。Hook と state の対応は「呼ばれた順番」でしか紐付けられないため、順序が変わるとリストが破綻する からです。

たとえば、こんな壊れ方を考えてみます。

tsx
function BadExample({ showName }: { showName: boolean }) {
  if (showName) {
    const [name, setName] = useState('alice');  // 条件付きで呼ぶ
  }
  const [age, setAge] = useState(30);
  // ...
}
  • 1 回目のレンダーで showName が true → フック呼び出しは 2 回(Hook #1 = name, Hook #2 = age)
  • 2 回目のレンダーで showName が false → フック呼び出しは 1 回(age だけ呼ばれる)

2 回目のレンダーで React は連結リストの先頭 Hook #1 を取り出しますが、これは前回 name のために使われていた Hook です。age に対して name 用の state が返ってしまい、state が「ずれた」状態になります。

触って確かめる: Hook がずれていく様子 ​

下のデモで、「条件分岐で Hook を囲む」チェックを入れて、showName のトグルをオン/オフしてみてください。オフにした瞬間、呼び出しとリストのずれが表で可視化されます。

コンポーネントのコード
function Profile() {  const [name] = useState("alice");  const [age] = useState(30);  useEffect(() => {}, []);  return <div>...</div>;}
Fiber.memoizedState に保存されている Hook リスト(初回レンダー時の確定)
Hook #1
name
next →
Hook #2
age
next →
Hook #3
effect
再レンダー時の呼び出しを、リストと順番でつき合わせる 整合
呼び出し順呼ばれたフック(開発者の意図)リストの i 番目(React が返すもの)結果
1useState("alice")意図: name Hook #1 中身: name一致
2useState(30)意図: age Hook #2 中身: age一致
3useEffect(...)意図: effect Hook #3 中身: effect一致
Hook を無条件にトップレベルで呼んでいる状態です。再レンダーでも呼び出し順が変わらないので、リストはそのまま再利用できます。

React は「呼び出し側が変数名 age を使いたがっている」という情報を持っていません。連結リストは 順番でしか整合性を保てない ため、条件分岐や早期 return の内側に Hook を置くと壊れる、というわけです。

これを機械的に検出するために、公式は eslint-plugin-react-hooks の利用を推奨しています。最初のうちはこのプラグインに叱ってもらうのが、一番安全だと思います。

まとめ ​

  • Hook の state は、Fiber ノードの memoizedState に 連結リスト として保持される
  • React は 呼ばれた順番 で連結リストを辿り、Hook と state を対応付ける
  • 内部では mount 用と update 用の Dispatcher を切り替えている
  • 条件分岐やループの中で Hook を呼ぶと、呼び出し順が変わってリストが破綻する
  • これが「Rules of Hooks(Hook は関数コンポーネントのトップレベルで呼ぶ)」の理由

さらに深く学ぶには ​

参照ソース ​