ReactのHooksについて、ステートフックの更新、関数コンポーネントがいつ再実行されるかや副作用フックがいつ呼び出されるかについて、調べてもシンプルに情報がまとまって出てこなかったのでメモします。
私はこの辺について理解していなかったために、ステートの更新で無限ループが発生し対処に苦しみました。
前提
以下のコードはnpx start creat-react-app プロジェクト名で生成されるプロジェクト名/srcディレクトリ内のApp.jsを編集してデバッグしていることを前提にしています。
ステートフックの基本
useStateで設定したステートの値を更新すると、再レンダリング、つまり関数コンポーネントが再実行されます。下記コードではdataには初期値として文字列fooが設定されていましたが、ボタンをクリックすると、barに変更されるので、コンポーネントが再実行されます。そしてそれ以降ボタンを押しても、ステートが更新されないので、これ以上再実行されることはありません。
import {useState} from 'react';
const str1 = "foo";
const str2 = "bar";
function App(){
console.log("1. レンダリング開始");
const [data,setData]=useState(str1);
const doChange=()=>{
console.log("2. doChange 呼び出し")
setData(str2)
}
return <div>
<button onClick={doChange}>ステート変更</button>
</div>;
}
export default App;
開発者ツールのconsoleの表示は以下のようになっています。
- 初回の呼び出しで「1.レンダリング開始」が表示されます
- ボタンを押すと「2.doChange呼び出し」が表示され、
dataにbarが代入され、ステートが更新されるため、コンポーネントが再実行され、再び「1.レンダリング開始」が表示されます - 再度ボタンを押すと「2.doChange呼び出し」が表示され、
dataにbarが代入されます。ステートは更新されていないのですが、なぜかコンポーネントが再実行され、再び「1.レンダリング開始」が表示されます(ここについて、なぜ再実行されるのかは分かりませんでした。) - これ以降ボタンを押しても、ステートが更新されず、コンポーネントが再実行されないので、「2.doChange呼び出し」が表示されるだけで、「1.レンダリング開始」は表示されません
ステートが更新されたかの判定基準
このように、ステートが更新されるとコンポーネントが再実行されますが、それではどのようの場合に「ステートが更新された」と判定されるのでしょうか?実はその基準は、前回のステートの値とObject.isで比較してfalseの場合です。
次のコードのobj1、obj2のように形だけ同じでも別のオブジェクトならObject.is(obj1,obj2)はfalseになります。つまり↑のコードと同様、ステートにobj1が入っている状態で、obj2が設定されると関数コンポーネントが再実行されます。
import {useState} from 'react';
const obj1 = { foo: 'bar' };
const obj2 = { foo: 'bar' };
function App(){
console.log("1. レンダリング開始");
const [data,setData]=useState(obj1);
const doChange=()=>{
console.log("2. doChange 呼び出し")
console.log("変更前のdataはobj2と等しいか?:"+Object.is(data,obj2))
console.log(Object.is(data,obj2))
setData(obj2)
}
return <div>
<button onClick={doChange}>ステート変更</button>
</div>;
}
export default App;
開発者ツールのconsoleの表示は以下のようになっています。obj1とobj2の形は同じですが、↑の文字列を設定した場合と同様、1回目、2回目のボタンのクリックではコンポーネントが再実行されますが、3回目以降はコンポーネントは再実行されなくなります。
副作用フックの呼び出しタイミング
副作用フックuseEffectに渡した関数はステートが更新されたときに実行されるのに加え、実は関数コンポーネントのマウント時、つまり初回にかならず実行されます。次のコードで確かめます。
import {useState,useEffect} from 'react';
const obj1 = { foo: 'bar' };
const obj2 = { foo: 'bar' };
function App(){
console.log("1. レンダリング開始");
const [data,setData]=useState(obj1);
const doChange=()=>{
console.log("2.doChange呼び出し")
setData(obj2)
}
useEffect(()=>{
console.log("3.useEffect呼び出し")
})
return <div>
<button onClick={doChange}>ステート変更</button>
</div>;
}
export default App;
開発者ツールのconsoleの表示は以下のようになっています。
- 初回の呼び出しで「1.レンダリング開始」が表示され、マウント時なので副作用フックも呼び出され、「3. useEffect呼び出し」が表示されます
- ボタンを押すと「2.doChange呼び出し」が表示され、コンポーネントが再実行され、再び「1.レンダリング開始」が表示されるとともに、副作用フックも呼び出され、「3. useEffect呼び出し」も表示されます
- 再度ボタンを押すと「2.doChange呼び出し」が表示され、コンポーネントが再実行され、再び「1.レンダリング開始」が表示されますが、この際は副作用フックは呼び出されないようです。
- これ以降ボタンを押しても、ステートが更新されないので、「2.doChange呼び出し」が表示されるだけで、「1.レンダリング開始」も「3. useEffect呼び出し」も表示されません
無限ループ
以上のことを理解しないでステートを安易に更新しようとすると、Too many re-rendersエラーが発生したり無限ループが発生してブラウザがクラッシュしたりしてしまいます。
Too many re-rendersエラー
次のコードはToo many re-rendersエラーが発生します。
import {useState} from 'react';
function App(){
const [data,setData]=useState({ foo: 'bar' });
setData({ foo: 'bar' })
return <div>
</div>;
}
export default App;
- 初回の呼び出しで
dataにオブジェクト{ foo: 'bar' }が設定される - 関数コンポーネント内で再度
dataにオブジェクト{ foo: 'bar' }が設定される -
dataに設定されていた{ foo: 'bar' }と今設定された{ foo: 'bar' }は形は同じだが、別のオブジェクトなので、Object.isで比較するとfalseとなる。つまりステートが更新されたと判定される - ステートが更新されたため、コンポーネントが再実行される
- 2に戻る→無限ループに陥って
Too many re-rendersエラー発生
Maximum update depth exceeded警告
エラーが発生する場合はエラー表示されるので気づきますが、表示は正常でも無限ループが発生していて警告だけの場合は気づかない可能性もあり注意が必要です。
次のコードは無限ループになり、consoleでは「Maximum update depth exceeded. This can happen when a component calls setState inside useEffect,...」という警告が発生します。
import {useState,useEffect} from 'react';
function App(){
const [data,setData]=useState({ foo: 'bar' });
useEffect(()=>{
setData({ foo: 'bar' })
})
return <div>
</div>;
}
export default App;
これは、
- 初回の呼び出しで
dataにオブジェクト{ foo: 'bar' }が設定される - 初回の呼び出しなので、副作用フックが呼ばれ、再び
dataに{ foo: 'bar' }が設定される - 設定前に
dataに設定されていた{ foo: 'bar' }と今設定された{ foo: 'bar' }は形は同じだが、別のオブジェクトなので、Object.isで比較するとfalseとなる。つまりステートが更新されたと判定される。 - ステートが更新されたため、コンポーネントの実行と副作用フックの呼び出しが再度行われ、無限ループに陥ってしまう
これは、ステートを使って表示するJSXを更新しようとする場合注意が必要です。次のコードも無限ループが発生してしまいます。ステートにJSXを設定していますが、JSX構文も一種のオブジェクトと解釈されます。そしてステートに前回と同じ形のJSXを代入しても、Object.isで比較してfalseと判定されてしまうので、ステートが更新されたと判定され、useEffectが再度呼ばれてしまうので無限ループが発生してしまうのです。
import {useState,useEffect} from 'react';
function App(){
const [data,setData]=useState(<div>test</div>);
useEffect(()=>{
setData(<div>test</div>)
})
return <div>
{data}
</div>;
}
export default App;
たちが悪い無限ループ
さらにたちが悪いのがエラーも警告も発生していないのに無限ループが発生している場合です。関数コンポーネント内でAjaxなどの時間がかかる処理をして、ステートを更新した場合などはこれに当たります。次のコードはsetTimeoutで一定時間後にステートが更新されるようにしています。手元の環境で実行したところ、最初は5秒に1回のループだったのが、なぜか時間がたてばたつほどループが増え、最終的には1秒当たりに何回もループが発生するようになり、ブラウザがクラッシュする時限爆弾となってしまいました。
import {useState} from 'react';
function App(){
const [data,setData]=useState({ foo: 'bar' });
let timer=setTimeout(()=>{
console.log("setData");
setData({ foo: 'bar' });
},5000)
return <div>
</div>;
}
export default App;
また、関数コンポーネント内でAjaxを使ってステートを更新しているために、無限ループが発生してしまっている例とその修正についても↓の記事で書いています。
結論
ステートフックの更新、関数コンポーネントがいつ再実行されるかや副作用フックがいつ呼び出されるかについての理解はエラーや無限ループを発生させないために必須だと感じました。各位の助けになれば幸いです。


