この記事はReact Advent Calendar 2019の最終日の記事です。
Hooksのルールに、「フックを呼び出すのはトップレベルのみ」というものがあります。if文の中でuseStateやuseEffectを呼んではいけないよ、ってことですね。
非常にシンプルなルールのため、「そういうもんなのかー」と別段深く考えることなく、Hooksを使っている人も多いと思います。
ただ、改めて考えてみるとこのルール、どういった実装になっているのか非常に気になります。そこで、Reactのソースコードから該当箇所を拾ってきました。間違いもあるかもしれないので、見つけたらお手柔らかに指摘いただけると助かります。
useStateを例にとって見ていく
useStateの実装内容については、下記のブログ記事で詳しく解説されているので、そちらを参考にしてください。
React HooksのuseStateがどういう原理で実現されてるのかさっぱりわからなかったので調べてみた
この記事では、useStateの実装内容にはあまり触れずに、Hooksをトップレベル以外で呼んではいけない理由にフォーカスを当ててコードを読んでいきます。
上記記事の中で、useStateの本体はmountStateおよび、updateStateであるとあります(mountStateはuseState定義時に呼び出されるメソッドで、updateStateは値の更新時に呼ばれるメソッド)。そして、その実装途中にあるmountWorkInProgressHook()とupdateWorkInProgressHook()が、Hooksの呼び出し順の制約と密接に関係しています。
function mountState<S>(
initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
const hook = mountWorkInProgressHook();
// 以下省略
}
他のHooksにも同様に、mount時に呼ばれる「mount〇〇」とupdate時に呼ばれる「update〇〇」が用意されています。
では、mountWorkInProgressHook()の実装はどうなっているかというと、
function mountWorkInProgressHook(): Hook {
const hook: Hook = {
memoizedState: null,
baseState: null,
queue: null,
baseUpdate: null,
next: null,
};
// 一部省略
if (workInProgressHook === null) {
// This is the first hook in the list
firstWorkInProgressHook = workInProgressHook = hook;
} else {
// Append to the end of the list
workInProgressHook = workInProgressHook.next = hook;
}
return workInProgressHook;
}
初めに、hookオブジェクトを定義&初期化しています。このオブジェクトには後々、Hooks定義時の初期値などがプロパティに格納されます。useState()であれば初期値(initialState)が、useEffect()であれば実行したい処理が格納されるイメージです。
const hook: Hook = {
memoizedState: null,
baseState: null,
queue: null,
baseUpdate: null,
next: null,
};
重要なのはその下にあるif文による分岐部分です。firstWorkInProgressHookやworkInProgressHookといった変数は、このファイル全域をスコープとする変数で、workInProgressHookには現在処理中のHooksが挿入されるようです。
if (workInProgressHook === null) {
// This is the first hook in the list
firstWorkInProgressHook = workInProgressHook = hook;
} else {
// Append to the end of the list
workInProgressHook = workInProgressHook.next = hook;
}
workInProgressHookがnullのときは、そのコンポーネントにおいて初めてHooksが定義された場合なので、素直にhookオブジェクトをworkInProgressHookとfirstWorkInProgressHookに挿入しています。
大事なのはelse節のほうで、2個目以降のHooks定義の場合は、現在のworkInProgressHook(hookオブジェクト)のnextプロパティに新しいhookオブジェクトを関連付けして、その上でworkInProgressHookを上書きしてreturnしています。
この処理により各hookオブジェクトは、自分の次に定義されたhookオブジェクトの参照を持つことになります。
returnされたworkInProgressHookは、各mount〇〇メソッド内で固有の処理がなされ、use〇〇それぞれの処理が実行されます。
コンポーネントupdate時の挙動
コンポーネントの更新時には、mount〇〇系のメソッドではなく、update〇〇系のメソッドが呼ばれることになります。そして、update〇〇の内部では、共通してupdateWorkInProgressHook()が呼ばれています。それでは、updateWorkInProgressHookの処理はどうなっているかというと、
function updateWorkInProgressHook(): Hook {
if (nextWorkInProgressHook !== null) {
// There's already a work-in-progress. Reuse it.
workInProgressHook = nextWorkInProgressHook;
nextWorkInProgressHook = workInProgressHook.next;
currentHook = nextCurrentHook;
nextCurrentHook = currentHook !== null ? currentHook.next : null;
} else {
// Clone from the current hook.
invariant(
nextCurrentHook !== null,
'Rendered more hooks than during the previous render.',
);
currentHook = nextCurrentHook;
const newHook: Hook = {
memoizedState: currentHook.memoizedState,
baseState: currentHook.baseState,
queue: currentHook.queue,
baseUpdate: currentHook.baseUpdate,
next: null,
};
if (workInProgressHook === null) {
// This is the first hook in the list.
workInProgressHook = firstWorkInProgressHook = newHook;
} else {
// Append to the end of the list.
workInProgressHook = workInProgressHook.next = newHook;
}
nextCurrentHook = currentHook.next;
// 一部省略
}
return workInProgressHook;
}
最初のif文によって大きく処理が分岐していていますが、おそらく多くの場合においてelse節の処理が実行されるっぽいので、そちらをメインに説明します。
まず初めに、nextCurrentHookの中身をcurrentHookに代入しています。nextCurrentHookには次に実行されるべきhookオブジェクトが格納されています。
次に、newHookを定義しています。newHookは、currentHookが持つプロパティのうち、nextプロパティをnullに変更している以外はcurrentHookと同じですね。
const newHook: Hook = {
memoizedState: currentHook.memoizedState,
baseState: currentHook.baseState,
queue: currentHook.queue,
baseUpdate: currentHook.baseUpdate,
next: null,
};
その下にあるif文は、mountWorkInProgressHook()のときに出てきた処理とほぼ同一です。
if (workInProgressHook === null) {
// This is the first hook in the list.
workInProgressHook = firstWorkInProgressHook = newHook;
} else {
// Append to the end of the list.
workInProgressHook = workInProgressHook.next = newHook;
}
そして最後にぽつんと書かれているnextCurrentHook = currentHook.next
という処理が、Hooksの呼び出し順の制約に大きく関わっています。
hookオブジェクトは、次に定義されたhookオブジェクトの参照を持っているという話がありました。nextCurrentHook = currentHook.next
で、次のhookオブジェクトをnextCurrentHookに代入し、次回updateWorkInProgressHook()が呼ばれた際にはそのhookオブジェクトをもとに処理を行っていくことになります。
つまり、Hooksは呼び出し順に依存しているわけです。mount時に3つのHooksを定義した場合、3つのhookオブジェクトが定義され、1つ目のhookオブジェクトが2つ目のhookオブジェクトへの参照を、2つ目のhookオブジェクトは3つ目のhookオブジェクトへの参照を持つことになります。この場合において、コンポーネント更新時にif文などによって2つ目のHooksが呼ばれなかった場合はどうなるか考えてみましょう。
ユーザーとしては1つ目と3つ目のhookオブジェクトを参照して各種Hooksメソッドが実行されてほしいのですが、Hooksはそういう仕様にはなっていないため、1つ目と2つ目のhookオブジェクトが利用され、各Hooksメソッドが実行されます。するとユーザーが期待する動作と実際の動作が異なってしまうため、Hooksはトップレベル以外で呼んではいけないよ、というルールになっているようです。
hookオブジェクトの関係性のように、配列内の特定の要素へ外部から参照できず、順番に前から一つずつ辿っていくしかないようなデータ構造をLinked Listと呼ぶそうです。このファイルにもコメントとして記述されていました。
// Hooks are stored as a linked list on the fiber's memoizedState field. The
// current hook list is the list that belongs to the current fiber. The
// work-in-progress hook list is a new list that will be added to the
// work-in-progress fiber.
参考:LinkedList と ArrayList の使い分け方
まとめ
全部解説しようとするとかなり長くなってしまうことが予想されたので、重要ポイントっぽい箇所に絞ってコードを拾うようにしました。興味がある人はぜひソースコードに目を通してみてください。あと、間違いがあればぜひ指摘してほしいです。