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を使ってステートを更新しているために、無限ループが発生してしまっている例とその修正についても↓の記事で書いています。
結論
ステートフックの更新、関数コンポーネントがいつ再実行されるかや副作用フックがいつ呼び出されるかについての理解はエラーや無限ループを発生させないために必須だと感じました。各位の助けになれば幸いです。