Reactのuseはなぜifの中で呼べるのか
React 19で追加されたuseはPromiseやContextなどのリソースから値を読むAPIだ。
useを使うと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 v19.0.0の実装を少し追ってみた。
なぜHookは条件分岐の中で呼べないのか
例えば次のコードはRules of Hooksに違反する。
// Rules of Hooksに違反する
function Component({ enabled }) {
if (enabled) {
const [count, setCount] = useState(0);
}
}
Reactの公式ドキュメントでは、HookはコンポーネントやCustom Hookのトップレベルで呼ぶ必要があり、条件分岐やループの中、early returnの後からは呼べないとされている。
useStateなどの状態を持つHookではReactは前回と今回のHookを呼び出し順で対応付けており、この対応を保つためにこれらのHookは毎回同じ順番で呼ぶ必要がある。
FiberのHook list
関数コンポーネントで使われるHookの状態はFiberのmemoizedStateからつながるlinked listとして管理されていて、Reactのソースにも次のコメントがある。
// Hooks are stored as a linked list on the fiber's memoizedState field.
FiberはReactがツリーを管理するために持つ内部データ構造で、コンポーネント関数そのものではなく、Reactツリー上のコンポーネントやDOM要素、テキストなどに対応する。
例えば次のように同じコンポーネントを2つ置いた場合も、それぞれに対応するFiberがあり、それぞれのFiberが自分のHookの状態を持つ。
<Counter />
<Counter />
イメージとしてはこんな感じになる。
CounterのFiber
|
+-- memoizedState
|
v
Hook
|
next
|
v
Hook
Hookを追加するときに使われるmountWorkInProgressHookの一部を抜き出すと、Hookが1つずつつながっていく様子が分かる。
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 — mountWorkInProgressHook
最初の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を対応付ける。
this render previous render
call 1 -> Hook 1
call 2 -> Hook 2
call 3 -> Hook 3
Reactはnameやageという変数名を見ておらず、何番目に呼ばれたかでHookの対応が決まる。
条件分岐と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
コード上ではageのためのuseStateだが、前回の2番目にあったのはnameなので次のような対応になる。
this render previous render
call 1 (count) -> Hook 1 (count)
call 2 (age) -> Hook 2 (name) コード上はageなのにnameのHook
linked listだから条件分岐が使えないわけではなく、前回と今回のHookを呼び出し順で対応付けているので、途中のHookが呼ばれなくなると以降の対応がずれる。
この仕組みがあるのでHookは毎回同じ順番で呼ぶ必要がある。
この例では、ずれたまま動き続けるわけではない。enabledがfalseになってHookの数が前回より減ると、レンダーの最後にReactが「Rendered fewer hooks than expected」というエラーを投げる。逆に、enabledがfalseの状態で描いた後にtrueになってHookが増えたときは、前回より多いHookを呼んだ時点で「Rendered more hooks than during the previous render.」というエラーになる。
ReactFiberHooks.js — finishRenderingHooks、ReactFiberHooks.js — updateWorkInProgressHook
useContextもHook listに入るのか
ここまでを見るとuseContextもFiberのmemoizedStateにつながるHook listに追加されていそうに見える。例えば次のコードなら、
useState(...);
useContext(ThemeContext);
useState(...);
Hook listは次のようになっていそうに見える。
useState
|
useContext
|
useState
実装を見てみると、ReactFiberHooks.jsにはこんなコメントがある。
// Non-stateful hooks (e.g. context) don't get added to memoizedState,
Contextのような状態を持たないHookはmemoizedStateに追加されないと書かれている。
実際にContextを読む処理も見てみる。開発時に使うdispatcher(各Hookの実装をまとめたオブジェクト)では、useContextは次のようになっている。
useContext<T>(context: ReactContext<T>): T {
currentHookNameInDev = 'useContext';
mountHookTypesDev();
return readContext(context);
},
ReactFiberHooks.js — HooksDispatcherOnMountInDEV
mountHookTypesDevは開発時にHookの名前を呼び出し順に記録する関数で、Contextの読み取り自体はreadContextに任せている。この先を追うとReactFiberNewContext.jsのreadContextForConsumerにたどり着く。
Contextの依存関係
readContextForConsumerは渡されたContextから現在の値を読み、どのContextを読んだかを記録する。読み取った値をvalueとして、依存関係を記録するnodeを作る部分を抜き出すと次のようになっている。
const contextItem = {
context: ((context: any): ReactContext<mixed>),
memoizedValue: value,
next: null,
};
ReactFiberNewContext.js — readContextForConsumer
読む値は渡されたContextから決まり、前回の何番目のHookかで取り出しているわけではない。
Fiberのdependencies.firstContextは最初のcontextItemを指し、2つ目以降は各contextItemのnextにつながる。このリストはContextの変更を検知するために使われ、Hookの状態を保存するmemoizedStateのリストとは別になっている。
Fiber
├─ memoizedState
│ └─ useStateのHook -> useStateのHook
│
└─ dependencies.firstContext
└─ 読んだContext -> 読んだContext
Fiberのdependencies.firstContextからつながるリストはレンダーごとに作り直され、今回読んだContextが記録される。例えば前回はThemeContextとUserContextを読んでいても、今回はUserContextだけ読んだなら、リストにはUserContextだけが残る。
ReactFiberNewContext.js — prepareToReadContext
なので、先ほどのようにuseStateの間でuseContextを呼んでも、Hook listだけを見ると次のようになる。
useState -> useState
useContextはContext用のHookをFiberのmemoizedStateに追加しておらず、後続のuseStateが使うHookの位置も進めない。
useContextとHookの順序チェック
そうなると次のように書いてもHook list上の対応はずれなさそうに見える。
// Rules of Hooksに違反する
if (enabled) {
useContext(ThemeContext);
}
それでもこの書き方はRules of Hooksに違反し、Reactの公式ドキュメントにもuseContextを条件分岐の中で呼ぶコードが違反例として載っている。
先ほどのuseContextにあったmountHookTypesDevの中身を見ると、Hook listとは別の配列にHookの名前を記録している。
const hookName = ((currentHookNameInDev: any): HookType);
if (hookTypesDev === null) {
hookTypesDev = [hookName];
} else {
hookTypesDev.push(hookName);
}
ReactFiberHooks.js — mountHookTypesDev
更新時のuseContextではupdateHookTypesDevが呼ばれ、前回と今回でHookの名前が同じ順番に並んでいるかを確認する。
hookTypesUpdateIndexDev++;
if (hookTypesDev[hookTypesUpdateIndexDev] !== hookName) {
warnOnHookMismatchInDev(hookName);
}
ReactFiberHooks.js — mountHookTypesDev / updateHookTypesDev
useContextはHook listにHookを追加しなくても、この順序チェックには含まれている。状態を保存するHook listの対応がずれないことと、Rules of Hooksに従っていることは別の話になる。
useとHookの順序チェック
useにContextを渡した場合も、内部では同じreadContextを呼んでいる。useの実装からContextを扱う分岐を抜き出すと次のようになっている。
} else if (usable.$$typeof === REACT_CONTEXT_TYPE) {
const context: ReactContext<T> = (usable: any);
return readContext(context);
}
開発用のdispatcherもuseにはこの関数をそのまま設定しており、useContextのようにmountHookTypesDevやupdateHookTypesDevを呼ぶ処理は挟んでいない。
// HooksDispatcherOnMountInDEVの一部
use,
// ...
useContext<T>(context: ReactContext<T>): T {
currentHookNameInDev = 'useContext';
mountHookTypesDev();
return readContext(context);
},
ReactFiberHooks.js — HooksDispatcherOnMountInDEV / HooksDispatcherOnUpdateInDEV
use(Context)はHook listの位置を進めず、開発時のHookの順序チェックにも含まれない。そのため、次のように条件分岐で呼ぶかどうかが変わっても、後続のHookの対応やチェックする順番はずれない。
function Component({ enabled }) {
const [count, setCount] = useState(0);
if (enabled) {
const theme = use(ThemeContext);
}
const [age, setAge] = useState(20);
}
enabledがtrueでもfalseでも、Hook listにはcountとageのHookが並び、開発時の順序チェックにも2つのuseStateだけが記録される。
ThemeContextへの依存関係は今回のレンダーで読んだ場合に記録されるので、条件分岐で読まなくなっても前回のContextとの対応がずれることはない。
Reactの公式ドキュメントにもuseContextとの違いが書かれている。
Unlike
useContext,usecan be called within loops and conditional statements likeif.
まとめ
use(Context)は渡されたContextから値を読み、依存関係をHook listとは別に記録する。呼ぶかどうかが変わっても後続のHookの対応はずれず、開発時のHookの順序チェックにも含まれない。
useContextもContextを読む処理は同じだが、Hookの順序チェックには含まれており、Rules of Hooksに従って毎回同じ順番で呼ぶ必要がある。
useとuseContextの違いがちょっと気になって調べ始めたけど、Hookの状態やContextの依存関係がどう管理されているかも知れてよかった。