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;