memo.nnenn0.com

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

React 19で追加されたuseはPromiseやContextなどのリソースから値を読むAPIだ。

useを使うとContextは次のように読める。

tsx
const theme = use(ThemeContext);

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

tsx
const theme = useContext(ThemeContext);

見た目だけならuseContextがuseという短いAPIになったようにも見えるが、この2つは呼べる場所が違う。

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

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

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

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

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

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

Reactの公式ドキュメントでは、HookはコンポーネントやCustom Hookのトップレベルで呼ぶ必要があり、条件分岐やループの中、early returnの後からは呼べないとされている。

React公式: Rules of Hooks

useStateなどの状態を持つHookではReactは前回と今回のHookを呼び出し順で対応付けており、この対応を保つためにこれらのHookは毎回同じ順番で呼ぶ必要がある。

FiberのHook list

関数コンポーネントで使われるHookの状態はFiberのmemoizedStateからつながるlinked listとして管理されていて、Reactのソースにも次のコメントがある。

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

ReactFiberHooks.js

FiberはReactがツリーを管理するために持つ内部データ構造で、コンポーネント関数そのものではなく、Reactツリー上のコンポーネントやDOM要素、テキストなどに対応する。

ReactWorkTags.js

例えば次のように同じコンポーネントを2つ置いた場合も、それぞれに対応するFiberがあり、それぞれのFiberが自分のHookの状態を持つ。

tsx
<Counter />
<Counter />

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

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

Hookを追加するときに使われるmountWorkInProgressHookの一部を抜き出すと、Hookが1つずつつながっていく様子が分かる。

js
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につないでいく。

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

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

  // ...
}

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

text
1 -> name
2 -> age
3 -> ref

次のレンダーではこのHook listを先頭から順番に辿り、その順番で今回呼ばれたHookと前回のHookを対応付ける。

text
this render     previous render
call 1      ->  Hook 1
call 2      ->  Hook 2
call 3      ->  Hook 3

Reactはnameやageという変数名を見ておらず、何番目に呼ばれたかでHookの対応が決まる。

条件分岐とHookの呼び出し順

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

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

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

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

enabledがtrueならHookは次の順番で呼ばれる。

text
1 -> count
2 -> name
3 -> age

次のレンダーでenabledがfalseになるとnameのuseStateは呼ばれない。

text
1 -> count
2 -> age

コード上ではageのためのuseStateだが、前回の2番目にあったのはnameなので次のような対応になる。

text
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に追加されていそうに見える。例えば次のコードなら、

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

Hook listは次のようになっていそうに見える。

text
useState
   |
useContext
   |
useState

実装を見てみると、ReactFiberHooks.jsにはこんなコメントがある。

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

ReactFiberHooks.js

Contextのような状態を持たないHookはmemoizedStateに追加されないと書かれている。

実際にContextを読む処理も見てみる。開発時に使うdispatcher(各Hookの実装をまとめたオブジェクト)では、useContextは次のようになっている。

js
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を作る部分を抜き出すと次のようになっている。

js
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のリストとは別になっている。

text
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だけを見ると次のようになる。

text
useState -> useState

useContextはContext用のHookをFiberのmemoizedStateに追加しておらず、後続のuseStateが使うHookの位置も進めない。

useContextとHookの順序チェック

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

tsx
// Rules of Hooksに違反する
if (enabled) {
  useContext(ThemeContext);
}

それでもこの書き方はRules of Hooksに違反し、Reactの公式ドキュメントにもuseContextを条件分岐の中で呼ぶコードが違反例として載っている。

React公式: Rules of Hooks

先ほどのuseContextにあったmountHookTypesDevの中身を見ると、Hook listとは別の配列にHookの名前を記録している。

js
const hookName = ((currentHookNameInDev: any): HookType);
if (hookTypesDev === null) {
  hookTypesDev = [hookName];
} else {
  hookTypesDev.push(hookName);
}

ReactFiberHooks.js — mountHookTypesDev

更新時のuseContextではupdateHookTypesDevが呼ばれ、前回と今回でHookの名前が同じ順番に並んでいるかを確認する。

js
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を扱う分岐を抜き出すと次のようになっている。

js
} else if (usable.$$typeof === REACT_CONTEXT_TYPE) {
  const context: ReactContext<T> = (usable: any);
  return readContext(context);
}

ReactFiberHooks.js — use

開発用のdispatcherもuseにはこの関数をそのまま設定しており、useContextのようにmountHookTypesDevやupdateHookTypesDevを呼ぶ処理は挟んでいない。

js
// HooksDispatcherOnMountInDEVの一部
use,
// ...
useContext<T>(context: ReactContext<T>): T {
  currentHookNameInDev = 'useContext';
  mountHookTypesDev();
  return readContext(context);
},

ReactFiberHooks.js — HooksDispatcherOnMountInDEV / HooksDispatcherOnUpdateInDEV

use(Context)はHook listの位置を進めず、開発時のHookの順序チェックにも含まれない。そのため、次のように条件分岐で呼ぶかどうかが変わっても、後続のHookの対応やチェックする順番はずれない。

tsx
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, use can be called within loops and conditional statements like if.

React公式: use

まとめ

use(Context)は渡されたContextから値を読み、依存関係をHook listとは別に記録する。呼ぶかどうかが変わっても後続のHookの対応はずれず、開発時のHookの順序チェックにも含まれない。

useContextもContextを読む処理は同じだが、Hookの順序チェックには含まれており、Rules of Hooksに従って毎回同じ順番で呼ぶ必要がある。

useとuseContextの違いがちょっと気になって調べ始めたけど、Hookの状態やContextの依存関係がどう管理されているかも知れてよかった。

参考