30歳からのプログラミング

30歳無職から独学でプログラミングを開始した人間の記録。

なぜ useEffect では無限ループが起こり得るのか

React のuseEffectは、その仕組み上、書き方によっては無限ループが発生してしまう。
それはなぜ発生するのか、そしてどう対処すればいいのか。一度理解してしまえば大した話でもないのだが、自分の理解を整理するために書いておく。

動作確認に使った React のバージョンは16.10.2

エフェクトが実行されるタイミング

原則として、関数コンポーネントが呼び出される度に、その関数に書かれてあるuseEffectが実行される。
実行のタイミングは、その関数コンポーネントが React element を返し、それによる DOM の変更が行われたあとになる。

関数コンポーネントが呼び出される条件については、以下に書いた。端的に言えば、stateに新しい値が渡されるか親コンポーネントが再呼び出しされるかすると、関数コンポーネントは再呼び出しされる。

numb86-tech.hatenablog.com

なので以下の例では、ボタンを押す度にuseEffectが実行され、ログが流れる。

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

function App() {
  const [state, setState] = useState(0);

  useEffect(() => {
    console.log('effect');
  });

  const onClick = () => {
    setState(s => s + 1);
  };

  return (
    <div>
      <p>{state}</p>
      <button type="button" onClick={onClick}>
        Click me
      </button>
    </div>
  );
}
export default App;

状態の更新とエフェクトの実行の無限ループ

stateに新しい値がセットされると関数コンポーネントが呼び出されてその都度useEffectが実行される、という仕組み上、もしuseEffectのなかで常にstateに新しい値を渡すようになっていた場合、以下のような無限ループが発生してしまう。

  1. コンポーネントがマウントされる
  2. useEffectが実行され、stateを更新する
  3. stateが更新されたので、コンポーネントが再び実行される
  4. useEffectが実行され、stateを更新する
  5. 以下、この繰り返し

以下のサンプルはこのパターンで、Profile関数コンポーネントが実行される度にuserProfileに(値は同じだが)参照が異なるオブジェクトをセットするので、関数呼び出しが止まらなくなる。

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

function Profile() {
  const [userId, setUserId] = useState(1);
  const [userProfile, setUserProfile] = useState(null);

  useEffect(() => {
    setUserProfile(
      userId === 1 ? {name: 'Alice', age: 20} : {name: 'Bob', age: 25}
    );
  });

  const onClick = () => {
    setUserId(currentId => (currentId === 1 ? 2 : 1));
  };

  return (
    <div>
      <p>{userId}</p>
      {userProfile && (
        <>
          <p>name: {userProfile.name}</p>
          <p>age: {userProfile.age}</p>
        </>
      )}
      <button type="button" onClick={onClick}>
        toggle user
      </button>
    </div>
  );
}
export default Profile;

エフェクトの実行に条件をつける

これを防ぐには、useEffectを常に実行するのではなく、条件を満たしたときにだけ実行するようにする必要がある。
useEffectの第二引数に配列を渡すことで、これが可能になる。
この第二引数の配列(以下、「依存配列」と呼ぶ)に値を入れておくと、その値が前回の関数呼び出し時から変化したときにのみ、useEffectが実行されるようになる。

先程のProfileuseEffectを以下のように書き換えると、userIdに更新があったときにのみuseEffectが実行される。
そのため、無限ループは発生しなくなり、マウントした時とボタンを押してuserIdが変更された時にのみ、useEffectが実行されるようになる。

  useEffect(() => {
    setUserProfile(
      userId === 1 ? {name: 'Alice', age: 20} : {name: 'Bob', age: 25}
    );
  }, [userId]);

依存配列の要素が変更されたかどうかの判定にはSameValueアルゴリズムを使っている。このアルゴリズムについても、以下の記事に書いている。

numb86-tech.hatenablog.com

SameValueアルゴリズムでは0-0を区別するため、以下の例ではボタンを押下する度にuseEffectが実行される。

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

function App() {
  const [state, setState] = useState(0);

  useEffect(() => {
    console.log('effect');
  }, [state]);

  const onClick = () => {
    setState(s => {
      const currentState = s;
      const nextState = 1 / currentState === Infinity ? -0 : 0;
      console.log(currentState);
      console.log(nextState);
      console.log(currentState === nextState); // === では常に true になる
      return nextState;
    });
  };

  return (
    <div>
      <p>{state}</p>
      <button type="button" onClick={onClick}>
        click
      </button>
    </div>
  );
}
export default App;

予期せぬ無限ループやバグを防ぐためにも、その関数スコープ内の値(statepropsなど)のうちuseEffectで使っているものについては、その値を依存配列に含めるべき。

eslint-plugin-react-hooksを入れてreact-hooks/exhaustive-depsを有効にしておけば、漏れがあった際にそれを検知してくれる。
setUserProfileによる無限ループの例も、正しく検知される。

f:id:numb_86:20191023115644g:plain

関数を依存配列に入れることで発生する無限ループ

しかし、とにかく依存配列さえ使えばよいという訳ではない。正しく理解して使わないと、やはり無限ループが発生してしまう。

依存配列に関数を入れる場合は、よく注意しないといけない。
ProfileコンポーネントのuseEffectを書き換え、プロフィール取得のロジックをgetUserProfileという別の関数に切り出した。
それをuseEffect内で使っているので、依存配列に含めた。react-hooks/exhaustive-depsも、そうするように指摘してくる。

  const getUserProfile = () =>
    userId === 1 ? {name: 'Alice', age: 20} : {name: 'Bob', age: 25};

  useEffect(() => {
    setUserProfile(getUserProfile());
  }, [getUserProfile]);

だがこれは、無限ループを生む。
なぜなら、getUserProfileProfile関数コンポーネントの呼び出し毎に新しく作られるため。
そのため、依存配列が更新されたと見做され、このuseEffectはコンポーネントが再呼び出しされる毎に実行されてしまう。
useEffectのなかでstateを更新していなければ取り敢えず無限ループは発生しないが、今回は更新してしまっている。

関数に限らず、関数コンポーネントのスコープ内の値は、関数コンポーネントが呼び出される度に新しく作られる。

以下の例はそれを示している。
ボタンを押してAppが呼び出される度に新たにsampleFuncが作られる。

import React, {useState} from 'react';

const funcArray = [];

const App = () => {
  const [state, setState] = useState(0);
  const sampleFunc = () => {};
  funcArray.push(sampleFunc);

  if (funcArray.length >= 2) {
    console.log(
      Object.is(
        funcArray[funcArray.length - 2],
        funcArray[funcArray.length - 1]
      )
    ); // false
  }

  return (
    <>
      {state}
      <button
        type="button"
        onClick={() => {
          setState(s => s + 1);
        }}
      >
        click
      </button>
    </>
  );
};

export default App;

これを回避する方法はいくつかある。

まず、sampleFuncを関数コンポーネントの外で定義する。そうすると、sampleFuncは同じ参照を指し続けることになる。

 import React, {useState} from 'react';

 const funcArray = [];
+const sampleFunc = () => {};

 const App = () => {
   const [state, setState] = useState(0);
-  const sampleFunc = () => {};
   funcArray.push(sampleFunc);

   if (funcArray.length >= 2) {
@@ -13,7 +13,7 @@ const App = () => {
         funcArray[funcArray.length - 2],
         funcArray[funcArray.length - 1]
       )
-    ); // false
+    ); // true
   }

   return (

あるいは、useCallbackを使うことでも対応できる。

-import React, {useState} from 'react';
+import React, {useState, useCallback} from 'react';

 const funcArray = [];

 const App = () => {
   const [state, setState] = useState(0);
-  const sampleFunc = () => {};
+  const sampleFunc = useCallback(() => {}, []);
   funcArray.push(sampleFunc);

   if (funcArray.length >= 2) {
@@ -13,7 +13,7 @@ const App = () => {
         funcArray[funcArray.length - 2],
         funcArray[funcArray.length - 1]
       )
-    ); // false
+    ); // true
   }

   return (

Profileも同じ要領で対応できる。
以下は、useCallbackを使った例。

  const getUserProfile = useCallback(
    () => (userId === 1 ? {name: 'Alice', age: 20} : {name: 'Bob', age: 25}),
    [userId]
  );

あるいは今回のケースだと、getUserProfileuseEffectのなかで定義すれば、getUserProfileを依存配列に含める必要がなくなる。

  useEffect(() => {
    const getUserProfile = () =>
      userId === 1 ? {name: 'Alice', age: 20} : {name: 'Bob', age: 25};

    setUserProfile(getUserProfile());
  }, [userId]);

ちなみに、useState()[1]useReducer()[1]、いわゆるsetStatedispatchは、同一性が維持されることが React によって保証されている。

import React, {useState} from 'react';

const funcArray = [];

const App = () => {
  const [state, setState] = useState(0);
  funcArray.push(setState);

  if (funcArray.length >= 2) {
    console.log(
      Object.is(
        funcArray[funcArray.length - 2],
        funcArray[funcArray.length - 1]
      )
    ); // true
  }

  return (
    <>
      {state}
      <button
        type="button"
        onClick={() => {
          setState(s => s + 1);
        }}
      >
        click
      </button>
    </>
  );
};

export default App;

参考資料