React 19のuseはなぜifの中で呼べるのか
React 19ではContextを次のように読めるようになった。
const theme = use(ThemeContext);
これまではuseContextを使っていた。
const theme = useContext(ThemeContext);
見た目だけならuseContextがuseという短い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は前回と今回の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.
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;
}
最初の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はnameやageという変数名を見ていない。何番目に呼ばれたかでHookの対応が決まる。
条件分岐すると対応がずれる
途中のHookを条件分岐させてみる。
function Component({ enabled }) {
const [count, setCount] = useState(0);
if (enabled) {
const [name, setName] = useState("");
}
const [age, setAge] = useState(20);
}
enabledがtrueならHookは次の順番で呼ばれる。
1番目 -> count
2番目 -> name
3番目 -> age
次のレンダーでenabledがfalseになるとnameのuseStateは呼ばれない。
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);
}
さらにReactFiberHooks.jsにはこんなコメントがある。
// Non-stateful hooks (e.g. context) don't get added to memoizedState,
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);
}
// ...
}
なので、
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,usecan be called within loops and conditional statements likeif.
公開API側のuseもdispatcherに処理を渡している。
export function use(usable) {
const dispatcher = resolveDispatcher();
return dispatcher.use(usable);
}
Reactの公式ドキュメントにはuseについて次の説明もある。
Despite its name,
useis not a Hook.
名前はuseから始まっている。
でもuseStateやuseContextと同じRules of Hooksに従うHookではない。
そのため条件分岐やループの中から呼ぶことができる。
まとめ
useContextはHook nodeをmemoizedStateに追加しない。それでもRules of Hooksに従うので、条件分岐の中からは呼べない。
useはReactの公式ドキュメントでHookではないと説明されている。Contextを渡した場合も条件分岐の中から呼べる。
最初はuseContextの短縮版くらいに思っていたけど、調べてみるとけっこう違うAPIだった。