⚛️ React Boundary
内部理解 — Hooks

Hooksは内部でどう動いているのか?

なぜHooksは条件分岐の中で使えないのか。useStateのstateはどこに保存されているのか。Reactの内部実装で理解します。

🎯 結論

HooksのstateはFiberオブジェクトに連結リストとして保存されています。

Reactは「何番目のHookか」でstateを管理するため、Hooksの呼び出し順が変わると間違ったstateが返ってしまいます。

🔍 よくある疑問:なぜ条件分岐でHooksを使えないのか?

❌ NGパターン
function Component({ show }) {
  if (show) {
    // ❌ 条件分岐の中でHook
    const [x, setX] = useState(0);
  }
  const [y, setY] = useState('');
  // ...
}
✅ OKパターン
function Component({ show }) {
  // ✅ 常にトップレベルで
  const [x, setX] = useState(0);
  const [y, setY] = useState('');
  
  // showによる処理は中で行う
  if (!show) return null;
  // ...
}

Reactのエラーメッセージ「Hooks must be called in the same order」が示す通り、Hooksは必ず同じ順番で呼ばれる必要があります。その理由が内部実装にあります。

🧠 Stateの保存場所:Fiberの連結リスト

各コンポーネントに対応するFiberオブジェクトにはmemoizedStateというフィールドがあります。 ここにHookのstateが連結リスト(linked list)として保存されます。

// Fiberオブジェクト(簡略版)
{
  type: MyComponent,
  memoizedState: {           // ← 1番目のHook (useState(0))
    memoizedState: 0,        //   現在の値
    queue: UpdateQueue,      //   更新キュー
    next: {                  // ← 2番目のHook (useState(''))
      memoizedState: '',
      queue: UpdateQueue,
      next: {                // ← 3番目のHook (useEffect)
        memoizedState: [deps],
        next: null
      }
    }
  }
}

なぜ順番が重要か

Reactは「このコンポーネントの1番目のuseState」「2番目のuseState」という形でstateを管理します。 レンダリングのたびにHookを順番に呼ぶことで、連結リストのノードと1対1で対応させています。 条件分岐でHookをスキップすると、「2番目のuseState」が実は「前回の3番目のuseState」と対応してしまいます。

⚙️ useStateの内部:更新キュー

setStateを呼ぶと、 Reactはすぐにstateを変えるのではなく、更新を「キュー」に積みます

// setCount(prev => prev + 1) を3回呼んだ場合
UpdateQueue: {
  updates: [
    { action: prev => prev + 1 },  // 1回目
    { action: prev => prev + 1 },  // 2回目
    { action: prev => prev + 1 },  // 3回目
  ]
}

// 次のrenderで全部処理されて +3 になる

バッチ処理(batching)

React 18では、イベントハンドラ内の複数のsetStateは自動でまとめて(バッチ)処理されます。 3回setStateを呼んでも再レンダリングは1回で済みます。 これもキューの仕組みがあるから実現できます。

📋 useEffectの内部:依存配列の比較

useEffectのHookノードには現在の依存配列が保存されています。 再レンダリング時にReactは前回の依存配列と今回を比較し、変化があった場合にのみeffectを再実行します。

// useEffectの依存比較(内部の疑似コード)
function areHookInputsEqual(nextDeps, prevDeps) {
  if (prevDeps === null) return false;
  
  for (let i = 0; i < prevDeps.length; i++) {
    // Object.is で比較(===とほぼ同じ)
    if (Object.is(nextDeps[i], prevDeps[i])) {
      continue;
    }
    return false; // 変化あり → effect再実行
  }
  return true; // 変化なし → effect スキップ
}

オブジェクト・関数は毎回「変化あり」になる

Object.isは参照比較なので、 毎回新しく作られるオブジェクト({})や関数(() => {})は 内容が同じでも「変化あり」とみなされます。 これがuseMemoやuseCallbackが必要になる理由です。

🔄 初回マウントと再レンダリングの切り替え

Reactは内部でHookの「マウント用」と「更新用」の2セットの実装を持っています。

// Reactの内部(簡略版)
const HooksDispatcherOnMount = {
  useState: mountState,    // 初回: 新しいHookノードを作る
  useEffect: mountEffect,
};

const HooksDispatcherOnUpdate = {
  useState: updateState,   // 更新: 既存のHookノードを使う
  useEffect: updateEffect,
};

// ReactCurrentDispatcher.current が
// マウント時と更新時で切り替わる

📌 まとめ

  • ✓ HooksのstateはFiberオブジェクトの連結リストに保存される
  • ✓ ReactはHookを「何番目か」で識別するため、呼び出し順が変わると誤動作する
  • ✓ setStateは「更新キューに積む」処理——即座に値が変わるわけではない
  • ✓ useEffectの依存配列はObject.is(参照比較)で比較される
  • ✓ オブジェクト・関数は毎回新しい参照になるためuseMemo/useCallbackが必要になる

関連記事