🕌

なぜReactのHookをIf分に入れてはいけないですか?

2022/07/30に公開

背景

React16.8以降ではフックが導入されています。開発の時に、一回useSelectというreduxのフックをif分に入れまして、lintに怒られました。そして、調べたところ、フックをif分に入れたり、returnの後ろに入れたりしてはいけないです。
公式サイトもそのように強く強調しています
https://ja.reactjs.org/docs/hooks-rules.html#only-call-hooks-at-the-top-level

フックをループや条件分岐、あるいはネストされた関数内で呼び出してはいけません

しかし、原因について公式の説明文をみてもなかなか解像度がつかず、ずっと疑問として抱えています。
この記事はReactのソースコードレベルから、なぜフックはif分に書いてはいけないですかを説明いたします。
イメジーをつけやすくために、スクショとgifをいっぱい使っています、何卒よろ即お願いたします。

説明するためなサンプルコード

今回は以下のサンプルコードを使って、説明いたします。

import { useEffect, useMemo, useState } from 'react';

function App() {
  const [num,setNum] = useState(0)

  useEffect(() => {
    console.log(num)
  },[num])

  const result = useMemo(() => {
    return num * num
  },[num])

  useEffect(() => {
    console.log(result)
  },[result])

  const handleOnClick = () =>{
    setNum((num) => num + 1)
  }
  
  return (
    <div className="container" onClick={handleOnClick}>
      {result}
    </div>
  );
}

export default App;

hook(フック)とfiberの関係

ここであった方が良い知識はReactのメモリに存在しているFiberNodeツリーです。
Reactは常に一個の既存FiberNodeツリーともう一個構築中のFiberNodeツリーを存在しています。
アップデートが走られた時に、既存のFiberNodeツリーを元に新しいのFiberNodeツリーを構築します。そして、既存のFiberNodeツリーを捨てて、新しく作られたFiberNodeツリーにfiberNodeから指しています。
この切り替えは高速にメモリ上で行っています。


図のように、左側の部分は構築中のFiberNodeツリーです。右側の部分は既存の構築済みなFiberNodeツリー。現在fiberRootNodeはcurrentポインターから右側の既存FiberNodeツリーを指しています。


FiberNodeにはたくさんプロパティーがあります。それぞれの役割は違います。特に今回注目していただきたいのはmemorizedStateというプロパティーです。

関数APPはメモリ上に該当するFiberNodeがあります。
このFiberNodeのmemorizedStateはフックのリンクリストになります。
※他のFiberNodeたとえば、DIV要素が該当するFiberNodeのmemorizedStateはclassName,onClick,childrenになります。フックリンクリストではないです。
FiberNodeのmemorizedStateはFiberNodeの種類によって、それぞれべつべつな値を持ちますので、必ずしてmemorizedStateはフックリンクリストとして覚える必要がないです。

関数APPに以下の四つのフックが並んでいます。
useState, useEffect, useMemo, useEffect
ぞれぞれのフックは関数Appの該当fiberNodeが初期レンダリング時に、fiberNodeのmemorizedStateというプロパーティーに追加しています。


図のように関数Appの該当FiberNodeのmemorizedStateはフックのリンクリストをつこまれてます。

memorizedStateに突っ込まれたフックのリンクリストはコード上と同じ順番です。なぜかというと、App関数を初めて実行されてから、memorizedStateにフックリンクリストが突っ込まれたわけです。
App関数内部は同期に順番で実行されてあるから、ぞれぞれのフックも順番でコードの上から下まで突っ込まれてきます。

アップデート時に関数AppのfiberNodeの挙動

クリックイベントによって、再レンダリングが走ったら、renderWithHooksによって、関数APPが実行され、FiberNodeツリーを構築するためなReact.Elementを返します。

関数Appはそれぞれの行まで実行されたらまずはどうなるのかを見ておきましょう

  1. 一番目のフックuseState

アップデート段階ではuseStateは内部のdispatcherのrerenderReducerをよんでいます。

そして、rerenderReducerはupdateWorkInProgressHookをよんできます。
updateWorkInProgressHookの中身をみたら、以下になっています。


function updateWorkInProgressHook() {
  // This function is used both for updates and for re-renders triggered by a
  // render phase update. It assumes there is either a current hook we can
  // clone, or a work-in-progress hook from a previous render pass that we can
  // use as a base. When we reach the end of the base list, we must switch to
  // the dispatcher used for mounts.
  var nextCurrentHook;

  if (currentHook === null) {
    var current = currentlyRenderingFiber$1.alternate;

    if (current !== null) {
      nextCurrentHook = current.memoizedState;
    } else {
      nextCurrentHook = null;
    }
  } else {
    nextCurrentHook = currentHook.next;
  }

  var nextWorkInProgressHook;

  if (workInProgressHook === null) {
    nextWorkInProgressHook = currentlyRenderingFiber$1.memoizedState;
  } else {
    nextWorkInProgressHook = workInProgressHook.next;
  }

  if (nextWorkInProgressHook !== null) {
    // There's already a work-in-progress. Reuse it.
    workInProgressHook = nextWorkInProgressHook;
    nextWorkInProgressHook = workInProgressHook.next;
    currentHook = nextCurrentHook;
  } else {
    // Clone from the current hook.
    if (!(nextCurrentHook !== null)) {
      {
        throw Error( "Rendered more hooks than during the previous render." );
      }
    }

    currentHook = nextCurrentHook;
    var newHook = {
      memoizedState: currentHook.memoizedState,
      baseState: currentHook.baseState,
      baseQueue: currentHook.baseQueue,
      queue: currentHook.queue,
      next: null
    };

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

  return workInProgressHook;
}

updateWorkInProgressHookが何をやっているのかをざっくり説明すると、右側の既存のFiberNodeツリーにある関数AppのfiberNodeのmemorizedStateを左側の構築中のFiberNodeツリーにある関数AppのfiberNodeのmemorizedStateにコピーします。

currentHookは右側に構築済みのFiberNodeツリーにあるApp関数のfiberNodeのhookのポインタです。
このポインタは一番左のhookから一番右のhookまで移動します。

現在たどりついてあるuseStateはApp関数の最初のHookですので、workInProgressHookはまたなにも入ってない、nullです。
そして、updateWorkInProgressHookは右側のcurrentHookの値をそのまま左側のworkInProgressHookに付与します。

次にたどり着いたのは2番目のuseEffectです

次にたどり着いたのは3番目のuseMemoです

次にたどり着いたのは4番目のuseEffectです

最後,currentHookとworkInProgressHookをリセットします

ここまでまとめます、4点が大事です

  1. App関数のfiberNodeのmemorizedStateプロパーティにフックが保存されてます
  2. 保存されてあるフックは順番ありのリンクリストです
  3. 保存されてあるフックの順番は決まりです、変わったり移動したりしていません
  4. アップデート時に、App関数のfiberNodeのhookの構築は既存のfiberNodeのhookを順番にコピーしています。

たとえば、フックがif分にはいったら、どうなるの

以下のコードを使って実験します

import { useEffect, useMemo, useState } from 'react';

function App() {
  const [num,setNum] = useState(0)

  if (num % 2 === 0) {
    useEffect(() => {
      console.log(num)
    },[num])
  }

  const result = useMemo(() => {
    return num * num
  },[num])

  useEffect(() => {
    console.log(result)
  },[result])

  const handleOnClick = () =>{
    setNum((num) => num + 1)
  }
  
  return (
    <div className="container" onClick={handleOnClick}>
      {result}
    </div>
  );
}

export default App;

現象、マウントしたら0が予想通りに表示されてますが、0をクリックしたら、warningとエラーがでてきます。

  • warningがでるところを分析

ここのwarningが発生する場所はuseMemoから入ってupdateHookTypesDevのところです。

hookTypesDevは前回のマウント時に生成され、これ以降変わらないとreact側が想定しています。

hookTypesDev: Array(4)
0: "useState"
1: "useEffect"
2: "useMemo"
3: "useEffect"

ですが、このクリックしてレンダリングする時に、二番目のuseEffectがジャンプしまして、直接useMemoのところに来て、hookTypesUpdateIndexDev=1になります。
でもuseMemoから入ってきたから、hookName="userMemo"になります
以下の条件が満たせないから、warning出力関数になります

hookTypesDev[hookTypesUpdateIndexDev] !== hookName

  • エラーができところを分析
    アップデート時に、useMemoから呼び出されたのはupdateMemoです。
    updateMemoの中から、現在のフックの前のレンダリング時にあったdependencyと現在のレンダリング時にあるdependencyと比較します。もし変化がないなら、useMemoの第一引数のコールバック関数を実行しないです。
    しかし、今回のレンダリング時は一個useEffectを飛び級しまして、useMemoにきました。Reactは前のレンダリング時にあったdependencyを取得したのは前のレンダリング時に現在飛び級したuseEffectフックのdependecyです。

    図のように枠にあるnextDepsとprevStateのデータ構造はそれぞれ違って、areHookInputsEqualの中から異常が検知され、エラーを吐き出しています。
    なぜそれぞれデータ構造は違うのは、それぞれ別々なフックから取り出されたわけです。

    図のように、渡されたpreDevsはundefinedになって、preDevs.lengthを取り出そうとしました。
    通常useMemoのdependencyデータ構造だと、preDevsはundefinedにならないはずです。


図のように、左側フックリンクは左方向に2ステップを進みましたが、右側のフックリンクは右方向へ1ステップだけ進みました。2ステップと1ステップのずれに伴うデータ構造のずれは本質な原因ですね。

結論

  • フックはfiberNodeのmemorizedStateに保存されてあります
  • 保存されてあるのはリンクリストです
  • 構築中のFiberNodeは既存のFiberNodeのmemorizedStateを材料として自分のmemorizedStateを構築しています
  • 飛び級したら、構築中のFiberNodeが作られてあるフックは自分が欲しくない材料を既存のFiberNodeのmemorizedStateから持ってきてしまいますので、エラーになります

Discussion