memo.nnenn0.com

React 19のuseはなぜifの中で呼べるのか

React 19ではContextを次のように読めるようになった。

const theme = use(ThemeContext);

これまではuseContextを使っていた。

const theme = useContext(ThemeContext);

見た目だけならuseContextuseという短いAPIになったようにも見える。

でも、この2つは呼べる場所が違う。

// Rules of Hooksに違反する
if (enabled) {
  const theme = useContext(ThemeContext);
}
// これは書ける
if (enabled) {
  const theme = use(ThemeContext);
}

同じContextを読んでいるのに、なぜuseだけ条件分岐の中で呼べるのか。

気になったのでHooksの仕組みとReactの実装を少し追ってみた。

なぜHookは条件分岐の中で呼べないのか

例えば次のコードはRules of Hooksに違反する。

// Rules of Hooksに違反する
function Component({ enabled }) {
  if (enabled) {
    const [count, setCount] = useState(0);
  }
}

Reactの公式ドキュメントではHookはコンポーネントやCustom Hookのトップレベルで呼ぶ必要があるとされている。

条件分岐やループの中からは呼べない。early returnの後から呼ぶこともできない。

React公式: Rules of Hooks

Reactは前回と今回のHookを呼び出し順で対応付けている。

この仕組みがあるのでHookは毎回同じ順番で呼ぶ必要がある。

HookはFiberのlinked listに保存される

関数コンポーネントで使われるHookの状態はFiberのmemoizedStateからつながるlinked listとして管理されている。

FiberはReactが各コンポーネントを管理するために持つ内部データ構造だ。

Reactのソースにも次のコメントがある。

// Hooks are stored as a linked list on the fiber's memoizedState field.

ReactFiberHooks.js

Fiberはコンポーネント関数そのものではない。

Reactツリー上のそれぞれのコンポーネントに対応する。

例えば、

<Counter />
<Counter />

と同じコンポーネントを2つ置いた場合も、それぞれに対応するFiberがある。

それぞれのFiberが自分のHookの状態を持つ。

イメージとしてはこんな感じになる。

CounterのFiber
  |
  +-- memoizedState
          |
          v
        Hook
          |
         next
          |
          v
        Hook

Hookを追加するときに使われるmountWorkInProgressHookを見ると、この構造が分かりやすい。

一部を抜き出すとこうなっている。

if (workInProgressHook === null) {
  // This is the first hook in the list
  currentlyRenderingFiber.memoizedState =
    workInProgressHook = hook;
} else {
  // Append to the end of the list
  workInProgressHook =
    workInProgressHook.next = hook;
}

ReactFiberHooks.js

最初のHookなら現在レンダーしているFiberのmemoizedStateに設定する。

2つ目以降なら前のHookのnextにつないでいく。

例えば次のコンポーネントがある。

function Profile() {
  const [name, setName] = useState("");
  const [age, setAge] = useState(20);
  const ref = useRef(null);

  // ...
}

かなり単純化するとHookは次のような順番で並ぶ。

1番目 -> name
2番目 -> age
3番目 -> ref

次のレンダーではこのHook listを先頭から順番に辿る。

その順番で今回呼ばれたHookと前回のHookを対応付ける。

今回1番目のHook呼び出し -> 前回1番目のHook
今回2番目のHook呼び出し -> 前回2番目のHook
今回3番目のHook呼び出し -> 前回3番目のHook

Reactはnameageという変数名を見ていない。何番目に呼ばれたかでHookの対応が決まる。

条件分岐すると対応がずれる

途中のHookを条件分岐させてみる。

function Component({ enabled }) {
  const [count, setCount] = useState(0);

  if (enabled) {
    const [name, setName] = useState("");
  }

  const [age, setAge] = useState(20);
}

enabledtrueならHookは次の順番で呼ばれる。

1番目 -> count
2番目 -> name
3番目 -> age

次のレンダーでenabledfalseになるとnameuseStateは呼ばれない。

1番目 -> count
2番目 -> age

コード上では2番目のHookはageになる。

でもReactから見ると「今回2番目に呼ばれたHook」になる。

前回の2番目にあったのはnameなので次のような対応になる。

今回1番目のHook呼び出し -> 前回1番目のHook(count)

今回2番目のHook呼び出し -> 前回2番目のHook(name)
                                  ↑
                            コード上ではage

linked listだから条件分岐が使えないわけではない。

前回と今回のHookを呼び出し順で対応付けているので、途中のHookが呼ばれなくなると以降の対応がずれる。

この仕組みがあるからRules of HooksではHookを毎回同じ順番で呼ぶ必要がある。

ではuseContextもHook nodeを1つ使っているのか

ここまでを見るとuseContextもlinked listの中に1つHook nodeを持っていそうに見える。

例えば、

useState(...);
useContext(ThemeContext);
useState(...);

なら、

useState
   |
useContext
   |
useState

となっていそうに見える。

実装を見てみると、そうはなっていなかった。

公開API側のuseContextはdispatcherのuseContextを呼び出している。

export function useContext(Context) {
  const dispatcher = resolveDispatcher();

  // ...

  return dispatcher.useContext(Context);
}

ReactHooks.js

さらにReactFiberHooks.jsにはこんなコメントがある。

// Non-stateful hooks (e.g. context) don't get added to memoizedState,

ReactFiberHooks.js

Contextのようなnon-stateful hookはmemoizedStateに追加されないとそのまま書かれている。

なので、

useState(...);
useContext(ThemeContext);
useState(...);

と書いてもmemoizedStateからつながるHookのlinked listだけを見るとざっくりこうなる。

useState -> useState

useContext用のHook nodeが2つのuseStateの間に入るわけではない。

それでもuseContextは条件分岐では呼べない

そうなると次のように書いてもHook list上の対応はずれなさそうに見える。

// Hook listの順番だけを見ると問題なさそうに見える
if (enabled) {
  useContext(ThemeContext);
}

それでもこの書き方はRules of Hooksに違反する。

Reactの公式ドキュメントにもuseContextを条件分岐の中で呼ぶコードが違反例として載っている。

function Bad({ cond }) {
  if (cond) {
    // 🔴 Bad: inside a condition
    const theme = useContext(ThemeContext);
  }

  // ...
}

React公式: Rules of Hooks

なので、

Hookは呼び出し順で対応付けられる
↓
だからHookは条件分岐の中で呼べない

という説明だけだとuseContextについては少し足りない。

useContextはContext用のHook nodeをmemoizedStateに追加していない。

それでもuseContextはHookなのでRules of Hooksに従う。

条件分岐の中から呼ぶことはできない。

useは条件分岐の中で呼べる

React 19では次のコードが書ける。

// これは書ける
if (enabled) {
  const theme = use(ThemeContext);
}

ReactのuseのドキュメントにもuseContextとの違いがそのまま書かれている。

Unlike useContext, use can be called within loops and conditional statements like if.

React公式: use

公開API側のuseもdispatcherに処理を渡している。

export function use(usable) {
  const dispatcher = resolveDispatcher();
  return dispatcher.use(usable);
}

ReactHooks.js

Reactの公式ドキュメントにはuseについて次の説明もある。

Despite its name, use is not a Hook.

React公式: use

名前はuseから始まっている。

でもuseStateuseContextと同じRules of Hooksに従うHookではない。

そのため条件分岐やループの中から呼ぶことができる。

まとめ

useContextはHook nodeをmemoizedStateに追加しない。それでもRules of Hooksに従うので、条件分岐の中からは呼べない。

useはReactの公式ドキュメントでHookではないと説明されている。Contextを渡した場合も条件分岐の中から呼べる。

最初はuseContextの短縮版くらいに思っていたけど、調べてみるとけっこう違うAPIだった。

References