[React Hooks] React ComponentライフサイクルをおさらいしながらuseEffect で無限ループが発生する条件について理解する

2021.03.08

useEffect で無限ループが発生した場合、原因を調査する上で注目すべき点に以下の 2 点があげられると思っています。

  1. React Componentライフサイクル と useEffect が実行されるタイミングがバッティングしている
  2. 上記以外のロジックのバグ(例えば、useEffect で発生した副作用を依存配列で参照してコンポーネントがまたレンダリングされてしまう、など)

① と ② のどちらが原因で無限ループが発生しているのかがわかるだけでもデバッグが少し楽になります。本記事では React Component のライフサイクルをおさらいしながら ① が原因で無限ループが発生するケースと解決方法を 3 つまとめてみました。

React Component ライフサイクルおさらい

React のコンポーネントがレンダリングされるタイミングは以下の3つです。

コンポーネントが:

  1. マウント(DOM の初回ロード)された時
  2. コンポーネントが更新された時
  3. アンマウントされた時

② は具体的にはコンポーネントに新しい props が渡された時や、コンポーネントの state が更新された時です。

useEffect が実行されるのはいつか?

次に、useEffect がいつ実行されるのかをおさらいします。

useEffect が実行されるのはコンポーネントが:

  1. マウント(DOM の初回ロード)された時
  2. レンダリングされた時
  3. アンマウントされた時

ただし、第二引数が指定されている場合は以下の動作になります。

  • マウント時以降は第二引数に渡された値が更新された時

第二引数(以下、依存配列)に useEffect にいつ発火してほしいかを指定することで、余計に useEffect が実行されるのを防ぐことができます。

これらの前提知識を踏まえた上で、useEffect で無限ループするケースをみてみましょう。

useEffect の中で setState して無限ループする

コンポーネントの state が更新される度に React はコンポーネントをレンダリングしますが、useEffect はコンポーネントがレンダリングされる度に 呼び出されるため無限ループが発生します。

import "./App.css";
import React, { useState, useEffect } from "react";

function App() {
  const [count, setCount] = useState(-1);

  useEffect(() => setCount(count + 1));

  return (
    <div>
      <div>Count: {count}</div>
    </div>
  );
}

export default App;

これに関しては ESLint のeslint-plugin-react-hooks を設定しておけば Warning が表示されるので容易く気付けます。

React Hook useEffect contains a call to 'setCount'. Without a list of dependencies, this can lead to an infinite chain of updates. To fix this, pass [count] as a second argument to the useEffect Hook.eslintreact-hooks/exhaustive-deps

解決方法

依存配列にsetCount関数を渡します。

依存配列にコンポーネント関数を含めることで無限ループする

以下のようにgetItem()という関数がコンポーネントに定義されている場合、コンポーネントがレンダリングされる度に新しいgetItem()関数が再作成されます。

その関数を useEffect の第二引数である依存配列に含ませてしまうことで useEffect がgetItemが更新されたものと判定し無限ループが発生します。

interface Item {
  name: string;
  price: number;
}

function App() {
  const [itemId, setItemId] = useState(1);
  const [item, setItem] = useState < Item > ({ name: "", price: 0 });

  // コンポーネントが再レンダリングされる度に新しいgetItem()が再作成される
  const getItem = () =>
    itemId === 1
      ? { name: "Apple", price: 100 }
      : { name: "Orange", price: 200 };

  useEffect(() => {
    setItem(getItem());
  }, [getItem]); // getItemが新しく作成されるとuseEffectが再実行される

  return (
    <div className="App">
      <header className="App-header">
        <p>Item Name: {item.name}</p>
      </header>
    </div>
  );
}

ESLint のreact-hooks/exhaustive-depsWarning も出ています。

The 'getItem' function makes the dependencies of useEffect Hook (at line 19) change on every render. Move it inside the useEffect callback. Alternatively, wrap the definition of 'getItem' in its own useCallback() Hook react-hooks/exhaustive-deps

解決方法

Warning にも書いてあるのですが以下の3通りの解決方法があります。

  1. getItem()を useEffect 内に記述する
  2. useCallBack を使う
  3. 関数の定義をコンポーネントの外に写す

getItem()を useEffect 内に記述する

useEffect 内に記述してしまうことで依存配列に含める必要がなくなります。

useCallBack を使う

useCallback でラップすることで、関数自体の依存が変わらない限り関数も変化しないことが保証されるようになります。

関数の定義をコンポーネントの外に移す

コンポーネントの外に関数の定義を移すことで、useEffect はコンポーネントが再レンダリングされた際に同じ関数を参照するため useEffect が再実行されるのを回避できます。

第二引数に何も指定しないで無限ループする

useEffect の第二引数に空配列も何も指定しなかった場合、コンポーネントがレンダリングされる度に useEffect が実行されますが、コンポーネントに保持している state をアップデートする度に React はコンポーネント関数を呼び出すため、コンポーネント内で state をセットしている場合は State に新しい値がセットされる 度にコンポーネントの再レンダリングが走り useEffect が再度呼び出される、の動作が繰り返されます。

参考:useEffect 完全ガイド:React にエフェクトを比較することを教える

解決方法

第二引数に空の配列を追加することでコンポーネントのレンダリングの度に useEffect が実行されるのを防ぐことができます。

おまけ:依存配列に嘘をついてもいいのか

第二引数に空の配列を追加することでコンポーネントがレンダリングされても useEffect は初回マウント時のみ発火するようになります。

最初の一度だけ useEffect 内の関数を呼び出したい時、依存配列に値を追加しようとする Warning を// eslint-disable-next-line react-hooks/exhaustive-depsで無効にして依存配列で参照すべき値をあえて含めたくない気持ちになることがあります。

仮に useEffect で参照しているものを依存配列に含めなかった場合はどうなるでしょうか?依存配列の役割はいつ useEffect が再実行されるべきかを React に教えることです。言い換えるとレンダリングのスコープ内で利用する全ての値は依存配列に含まれる必要があります。

実際に依存配列にスコープで利用している値を含めなかった場合、useEffect で発生した副作用を追うことができずに最終的に別の場所でのバグにつながりますが、その原因を特定するのはとても大変です。

以下の React 公式の FAQ にも依存配列に useEffect で参照しているものを省略することは非推奨であると明記されています。以下抜粋です。

副作用関数の外側にある関数内でどの props や state が使われているのか覚えておくのは大変です。ですので副作用関数内で使われる関数は副作用関数内で宣言するのがよいでしょう。そうすればコンポーネントスコープ内のどの値に副作用が依存しているのかを把握するのは容易です。やはりコンポーネントスコープ内のどの値も使用していないのであれば、[] を指定することは安全です

最後に

ちょっと長いですが、useEffectを理解するのにuseEffect完全ガイドがとても参考になりました。興味がある方はぜひ読んでみてください。

References