こんにちは。
個人開発中にuseEffectを含む処理を書いていた際、以下のようなエラーが発生しました。
※視認性向上のため一部筆者で改行を入れています。
Error: Calling setState synchronously within an effect can trigger cascading renders
Effects are intended to synchronize state between React and external
systems such as manually updating the DOM, state management libraries,
or other platform APIs. In general, the body of an effect should
do one or both of the following:
* Update external systems with the latest state from React.
* Subscribe for updates from some external system, calling setState
in a callback function when external state changes.
Calling setState synchronously within an effect body causes
cascading renders that can hurt performance, and is not recommended.
(https://react.dev/learn/you-might-not-need-an-effect).
このようなエラーが起きた原因と対策を備忘録代わりに書いていきます。
4/6追記
コメントで頂いたご指摘をもとに内容を加筆修正しました。
エラーが起きたコード
const [seconds, setSeconds] = useState<number>(0);
const [padMinutes, setPadMinutes] = useState<string>("00");
const [padSeconds, setPadSeconds] = useState<string>("00");
const timerIdRef = useRef<number | null>(null);
useEffect(() => {
// setInterval内のIDを初期化する処理
if (seconds <= 0 && timerIdRef.current !== null) {
clearInterval(timerIdRef.current);
timerIdRef.current = null;
};
// タイマーの10の位・1の位の数を2桁のゼロパディングをする処理
setPadMinutes(Math.floor(seconds / 60).toString().padStart(2, "0"));
setPadSeconds((seconds % 60).toString().padStart(2, "0"));
}, [seconds]);
上記のsetPadMinutes関数の部分で問題のエラーが発生していました。
エラーの文章の翻訳
エラーが起きた時真っ先にすべきことは、エラーの内容がどんなものかの特定です。
まずはGoogle翻訳を頼ってエラーの文章を和訳してもらいました。
エラー: エフェクト内でsetStateを同期的に呼び出すと、カスケードレンダリングが発生する可能性があります。
エフェクトは、Reactと外部システム(DOMの手動更新、状態管理ライブラリ、その他のプラットフォームAPIなど)間の状態を同期するために使用されます。一般的に、エフェクトの本体は以下のいずれか、または両方を実行する必要があります。
- Reactから取得した最新の状態を使用して外部システムを更新する。
- 外部システムからの更新を購読し、外部システムの状態が変更されたときにコールバック関数内でsetStateを呼び出す。
エフェクト本体内でsetStateを同期的に呼び出すと、パフォーマンスを低下させるカスケードレンダリングが発生するため、推奨されません。(https://react.dev/learn/you-might-not-need-an-effect)
どうやらuseEffect内でsetState関数を同期的に呼び出していることが問題のようです。
エラー文に記載のドキュメントを見てみる
4/6追記 一部内容を追記しました。
エラー文に記載されたドキュメントを参照してみます。
外部システムが関与していない場合(例えば、props や state の変更に合わせてコンポーネントの state を更新したい場合)、エフェクトは必要ありません。
不要なエフェクトを削除することで、コードが読みやすくなり、実行速度が向上し、エラーが発生しにくくなります。
エフェクトは、外部システムと同期したい場合には必要です。例えば、React の state と jQuery ウィジェットを同期させるエフェクトを書くことができます。
また、エフェクトでデータを取得し、例えば現在の検索クエリと検索結果を同期させることができます。
ただし、モダンなフレームワークは、コンポーネント内で直接エフェクトを書くよりも効率的な組み込みデータ取得メカニズムを提供していることに注意してください。
今回のコードを見返すと、setPadMinutes()とsetPadSeconds()は外部システムが関与していない関数です。
なのでuseEffectを使うと複雑で非効率なコードになってしまうことがわかりました。
今回のような関数はstateに入れず、レンダー中に計算する方が高速かつ簡潔なコードになります。
対策例
4/6追記 頂いた内容を元にコードを加筆修正しました。
useEffectの内部でsetStateを呼び出したい場合は非同期処理にすることで該当のエラーは解消されました。
下記のコードでもエラー自体は解決しますが、対処的な方法に過ぎません。
useEffect(() => {
if (seconds <= 0 && timerIdRef.current !== null) {
clearInterval(timerIdRef.current);
timerIdRef.current = null;
}
// useEffect内でsetStateを処理する箇所を非同期処理に変更
const setZeroPadding = async () => {
await setPadMinutes(Math.floor(seconds / 60).toString().padStart(2, "0"));
await setPadSeconds((seconds % 60).toString().padStart(2, "0"));
};
setZeroPadding();
}, [seconds]);
本質的な問題解決としては、useEffectでstateを使って処理せずに、レンダー中に値を計算する記述に切り替えた方がより効果的でした。
const [seconds, setSeconds] = useState<number>(0);
const timerIdRef = useRef<number | null>(null);
// ゼロパディングの処理はstateを使わなくても実現できるため削除
- const [padMinutes, setPadMinutes] = useState<string>("00");
- const [padSeconds, setPadSeconds] = useState<string>("00");
function App() {
// タイマー処理は「外部システムとの同期」扱いなのでuseEffectでOK
useEffect(() => {
if (seconds <= 0 && timerIdRef.current !== null) {
clearInterval(timerIdRef.current);
timerIdRef.current = null;
}
}, [seconds]);
// ゼロパディングの処理はuseEffectの外側で記述する
const padMinutes = Math.floor(seconds / 60).toString().padStart(2, "0");
const padSeconds = (seconds % 60).toString().padStart(2, "0");
}
最後に
useEffect内で「Calling setState〜」エラーが出た時は、まずはuseEffect内に無駄な処理がないかを見ておくべき、useEffectの外に書ける処理がないか見ておくべきということがわかりました。
また、エラーが出た時はエラー文の内容を正確に把握することが重要と改めて実感しました。また、エラー文内にドキュメントが記載されている際は、そのドキュメントに大きなヒントが隠されていることも実感しました。