React のuseEffectは、その仕組み上、書き方によっては無限ループが発生してしまう。
それはなぜ発生するのか、そしてどう対処すればいいのか。一度理解してしまえば大した話でもないのだが、自分の理解を整理するために書いておく。
動作確認に使った React のバージョンは16.10.2。
エフェクトが実行されるタイミング
原則として、関数コンポーネントが呼び出される度に、その関数に書かれてあるuseEffectが実行される。
実行のタイミングは、その関数コンポーネントが React element を返し、それによる DOM の変更が行われたあとになる。
関数コンポーネントが呼び出される条件については、以下に書いた。端的に言えば、stateに新しい値が渡されるか親コンポーネントが再呼び出しされるかすると、関数コンポーネントは再呼び出しされる。
なので以下の例では、ボタンを押す度に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に新しい値を渡すようになっていた場合、以下のような無限ループが発生してしまう。
- コンポーネントがマウントされる
useEffectが実行され、stateを更新するstateが更新されたので、コンポーネントが再び実行されるuseEffectが実行され、stateを更新する- 以下、この繰り返し
以下のサンプルはこのパターンで、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が実行されるようになる。
先程のProfileのuseEffectを以下のように書き換えると、userIdに更新があったときにのみuseEffectが実行される。
そのため、無限ループは発生しなくなり、マウントした時とボタンを押してuserIdが変更された時にのみ、useEffectが実行されるようになる。
useEffect(() => { setUserProfile( userId === 1 ? {name: 'Alice', age: 20} : {name: 'Bob', age: 25} ); }, [userId]);
依存配列の要素が変更されたかどうかの判定にはSameValueアルゴリズムを使っている。このアルゴリズムについても、以下の記事に書いている。
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;
予期せぬ無限ループやバグを防ぐためにも、その関数スコープ内の値(stateやpropsなど)のうちuseEffectで使っているものについては、その値を依存配列に含めるべき。
eslint-plugin-react-hooksを入れてreact-hooks/exhaustive-depsを有効にしておけば、漏れがあった際にそれを検知してくれる。
setUserProfileによる無限ループの例も、正しく検知される。

関数を依存配列に入れることで発生する無限ループ
しかし、とにかく依存配列さえ使えばよいという訳ではない。正しく理解して使わないと、やはり無限ループが発生してしまう。
依存配列に関数を入れる場合は、よく注意しないといけない。
ProfileコンポーネントのuseEffectを書き換え、プロフィール取得のロジックをgetUserProfileという別の関数に切り出した。
それをuseEffect内で使っているので、依存配列に含めた。react-hooks/exhaustive-depsも、そうするように指摘してくる。
const getUserProfile = () => userId === 1 ? {name: 'Alice', age: 20} : {name: 'Bob', age: 25}; useEffect(() => { setUserProfile(getUserProfile()); }, [getUserProfile]);
だがこれは、無限ループを生む。
なぜなら、getUserProfileはProfile関数コンポーネントの呼び出し毎に新しく作られるため。
そのため、依存配列が更新されたと見做され、この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] );
あるいは今回のケースだと、getUserProfileをuseEffectのなかで定義すれば、getUserProfileを依存配列に含める必要がなくなる。
useEffect(() => { const getUserProfile = () => userId === 1 ? {name: 'Alice', age: 20} : {name: 'Bob', age: 25}; setUserProfile(getUserProfile()); }, [userId]);
ちなみに、useState()[1]やuseReducer()[1]、いわゆるsetStateやdispatchは、同一性が維持されることが 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;