12k
All articles

React で「Maximum Update Depth Exceeded」を解消する方法

Reactのmaximum update depth exceededを、レンダーのループ、effect依存、ハンドラ、高頻度イベントの対策で修正します。

OpenReplay Team
OpenReplay Team
React で「Maximum Update Depth Exceeded」を解消する方法

「Maximum update depth exceeded」は、コンポーネントが無限レンダリングループに陥っていることを意味します。状態更新が再レンダリングを引き起こし、その再レンダリングが同じ状態更新を再び発生させるという循環です。React はブラウザのフリーズを防ぐため、ネストされた更新が 50 回(NESTED_UPDATE_LIMIT)を超えた時点で処理を中断します。

タブが固まる中、コンソールに同じ赤い行が積み上がっていくのを眺めた経験があるなら、あの大量のテキストが最初どれほど役に立たなく感じるかお分かりでしょう。幸いなことに、どのパターンを踏んでしまったのかさえ特定できれば、修正はほぼ常に 1 行の変更で済みます。

本ガイドでは、高頻度ハンドラのケースを含め、原因のすべてを網羅します。それぞれについてコピー&ペースト可能な before/after、素早く該当箇所へたどり着くための症状別ルックアップテーブル、そして問題のセッターを 1 分以内に特定するためのツールを紹介します。根本原因は関数コンポーネントでもクラスコンポーネントでも同一で、収束しない更新ループです。

要点

  • このエラーは無限レンダリングループです。React はネストされた更新回数が 50 を超えた時点で停止します(reconciler ソース内の NESTED_UPDATE_LIMIT)。
  • onClick={handleClick()} はレンダー中に関数を呼び出し、レンダーのたびに状態更新をスケジュールします。onClick={handleClick} を渡し、引数が必要な場合は onClick={() => handleClick(id)} としてください。
  • 最も汎用性の高い修正は関数型更新(setCount(prev => prev + 1))です。これによりその状態を effect の依存配列から外すことができ、read-then-write ループを断ち切れます。
  • useCallback が安定させるのは関数の「同一性」であり、「実行頻度」ではありません。そのため onScroll や dnd-kit の onDragMove のような高頻度ハンドラに起因するループは解消できません。代わりに状態更新をスロットリングまたはデバウンスしてください。
  • クラスコンポーネントの #185 エラーは開発環境でも本番環境でも throw されますが、useEffect 版は開発環境限定の警告です。本番環境では、throw もコンソールへのシグナルもないままループが走ります。

症状 → 原因 → 修正のルックアップ

自分が見ている症状に合致する行を探し、該当する修正へ進んでください。

症状原因修正
クリックしていないのにループする、マウント時に始まるonClick={fn()} がレンダー中にセッターを呼んでいるonClick={fn} を渡す
コンポーネントのトップレベルでの setStateレンダーパス内での状態更新ハンドラまたは effect 内へ移動する
effect がレンダーのたびに発火する依存配列の欠落・誤り、または依存配列内のインラインなオブジェクト/配列依存配列を修正 + useMemo/useCallback
effect が同じ状態を読み書きしているその状態が自身の依存配列に入っている関数型更新を使い、依存を外す
ドラッグ/スクロール/リサイズ中だけループする高頻度イベントごとに setState している更新をスロットリング/デバウンスする
親と子が互いに上書きし合う双方向の状態同期データフローを一方向にする

修正 1・2: ハンドラの参照とレンダーパス内での setState

最も手早く効果が出るのは、ハンドラの結び付け方とセッターを呼ぶ場所の 2 点です。onClick={handleClick()} はレンダー中に関数を呼び出し、レンダーのたびに状態更新をスケジュールしてしまいます。ほぼ常に onClick={handleClick} を渡すべきであり、引数を渡す必要がある場合は onClick={() => handleClick(id)} とします。

// BAD: acceptTerms runs during render, every render
<input type="checkbox" onChange={acceptTerms()} />

// GOOD: pass the reference; wrap in an arrow to pass args
<input type="checkbox" onChange={acceptTerms} />
<button onClick={() => selectItem(item.id)}>Select</button>

コンポーネント本体で直接セッターを呼び出した場合も、同じループが発生します。状態更新はイベントハンドラか effect の中で行うべきものであり、レンダーパスで行ってはいけません。

// BAD: runs on every render → loop
function Counter() {
  const [count, setCount] = useState(0);
  setCount(count + 1);
  return <div>{count}</div>;
}

// GOOD: update in response to an event
const increment = () => setCount(c => c + 1);

修正 3・4・5: effect の依存配列によるループ

effect のループの多くは、同一性が変化する依存値か、読み取った状態を自ら書き込む effect に起因します。依存配列にインラインで書かれたオブジェクトリテラルや配列リテラルは、レンダーのたびに新しい同一性を持つため、effect が毎レンダー再実行されます。参照を安定させるため、useMemo(オブジェクト/配列)または useCallback(関数)でラップしてください。

// BAD: options is a new object each render → effect re-runs forever
const options = { limit: 10, sort: 'date' };
useEffect(() => { search(query, options).then(setResults); }, [query, options]);

// GOOD: memoize so the reference is stable
const options = useMemo(() => ({ limit: 10, sort: 'date' }), []);

最も汎用性の高い修正は関数型更新です。effect が同じ状態を読み書きしている場合、setCount(prev => prev + 1) はクロージャではなく updater の引数から前の値を読み取るため、その状態を依存配列から取り除くことができ、ループが断ち切れます。

// BAD: count is read and written, and it's in deps
useEffect(() => { setCount(count + 1); }, [count]);

// GOOD: functional updater removes the dependency
useEffect(() => { setCount(prev => prev + 1); }, []);

effect が依存する関数については、正しい依存配列を指定した useCallback でラップするか、effect の内部へ移動させてください。effect 内で宣言された関数は実行ごとに 1 回生成されるため、依存として扱う必要がありません。

修正 6: 高頻度ハンドラをスロットリングする

useCallback はレンダーをまたいで関数の同一性を安定させますが、関数が実行される頻度は変えません。したがって、onScrollonMouseMoveonResize、dnd-kit の onDragMove といった高頻度ハンドラに起因する無限ループは解消できません。これらのイベントは 1 秒間に数十回発火し、その setState のたびに新たなレンダーがスケジュールされます。代わりに状態更新をスロットリングまたはデバウンスしてください。

// BAD: fires dozens of times/sec while dragging
const handleDragMove = (event) => setDragPreview(compute(event));

// GOOD: cap the update rate; useCallback alone won't help
import { throttle } from 'lodash';
const handleDragMove = throttle((event) => setDragPreview(compute(event)), 100);

lodash の throttle は、ラップした関数が一定時間内に発火できる回数に上限を設けます。ネイティブの requestAnimationFrame やデバウンスでも構いません。重要なのは頻度の制御であり、参照の安定性ではありません。

修正 7: 親子間の同期ループ

双方向の状態伝播(子の effect が親のセッターを呼び、それが子を再レンダリングし、再び effect が発火する)も典型的なループです。状態を単一の所有者に引き上げてデータフローを一方向に保つか、effect を介して値を同期し返すのではなく、親側で値を変換してください。

素早く特定する(およびクラスコンポーネントのケース)

トップダウンで進めましょう。まずスタックトレースを読み、ループの起点にある名前付きのセッターを突き止めます。次に React DevTools の Profiler を開き、休みなく再レンダリングされているコンポーネントを見つけます。どの prop や state の同一性が変化したかを確認するには、why-did-you-render(React 19 でテスト済み、開発環境専用、React Compiler との組み合わせは未検証)を導入します。これらは lint 時に検出することも可能です。eslint-plugin-react-hooks 7.x 以降、このプラグインには専用の set-state-in-render および set-state-in-effect ルールが同梱されており、アプリを実行する前に最も一般的な 2 つのトリガーを検出できます。いずれもデフォルトの recommended プリセットに含まれているため、プラグインをアップグレードするだけで有効になります。recommended-latest はその上に実験的な compiler ルールを重ねるだけです。set-state-in-render は、コンポーネントがレンダー中に何のガードもなく状態をセットした場合に発火します。これはまさにループへと螺旋状に発展していく形です。

このエラーは関数コンポーネントとクラスコンポーネントのどちらでも根本原因は同じです。クラスコンポーネントの場合、通常はレンダー中に setState を呼んでいるか、componentDidUpdate 内で無条件に呼んでいることを意味します。実行環境にも注意してください。クラスコンポーネントの #185 エラーは開発環境でも本番環境でも throw されますが、useEffect 版は開発環境限定の警告にすぎません。reconciler のソースでは、ネストされた passive update のチェックは開発環境限定のガードの内側にあり、throw ではなくコンソールへのログ出力を行います。そのため本番ビルドでは、その effect ループが throw もエラーバウンダリの発動もコンソールへのシグナルもないまま実行され、タブのフリーズという形でしか表面化しません。

この本番環境でのギャップこそ、ループのデバッグコストを跳ね上げる要因です。コンソールにはエラーは表示されても、どの操作がそれを引き起こしたのかは分かりませんし、effect のループではエラーがまったく表示されない場合もあります。OpenReplay のようなセッションリプレイツールは、コンソールエラーが存在する場合はそれを、そこに至るまでのユーザー操作の一連の流れとあわせて記録します。これにより、素っ気ないスタックトレース(あるいは無言のフリーズ)が、上記の修正に対して再現・検証できるクリック単位のケースへと変わります。

自分の症状を表の行と照合できたなら、必要な変更はたいてい 1 行です。呼び出しではなく参照を渡す、オブジェクトを useMemo で包む、あるいは依存を 1 つ外せるようにする関数型更新にする、といったものです。exhaustive-deps と新しい set-state-in-* ルールを設定して、次のループがユーザーのブラウザではなく lint ステップで落ちるようにしておきましょう。

FAQ

このエラーの useEffect 版と React エラー #185 の違いは何ですか?

この 2 つは実行時の挙動が異なる、まったく別の文字列です。エラー #185 はクラスコンポーネント向けの文言で、componentWillUpdate や componentDidUpdate 内の setState に言及しており、開発環境でも本番環境でも throw されます。useEffect 版は reconciler のソースにある別個の開発環境限定の警告で、開発環境限定のガードの内側に置かれています。そのため本番環境では、effect のループが throw もエラーバウンダリの発動もコンソールへのシグナルもまったくないまま実行されます。

ドラッグやスクロール中の無限ループが useCallback を追加しても止まらないのはなぜですか?

useCallback はレンダーをまたいで関数の同一性を安定させるものであり、その関数が実行される頻度は変えないためです。onScroll、onMouseMove、dnd-kit の onDragMove のような高頻度ハンドラは 1 秒間に数十回発火し、関数参照がメモ化されているかどうかにかかわらず、setState のたびに新たなレンダーがスケジュールされます。解決策は頻度の制御です。lodash の throttle、デバウンス、あるいは requestAnimationFrame を使って、状態更新そのものをスロットリングまたはデバウンスしてください。

関数型更新の形にすれば、常に effect の依存配列から状態を取り除けますか?

effect がその状態を必要とする理由が、次の値を計算することだけである場合に限られます。setCount(prev => prev + 1) と書けば、クロージャではなく updater の引数から前の値を読み取るため、count を依存配列から外すことができ、read-then-write のループが断ち切れます。分岐処理や別の関数への引き渡しなど、effect が他のロジックのためにもその状態を読み取っている場合は、依然として依存として必要であり、別の方法でループを断ち切らなければなりません。

開発環境ではエラーが出るのに、本番ビルドでは何も出ずにフリーズするだけなのはなぜですか?

useEffect 版のネストされた passive update のガードが、React reconciler 内で開発環境限定のチェックに包まれているためで、開発中にのみコンソール警告を出力します。本番ビルドではそのガードが実行されないため、同じ effect のループがエラーの throw もコンソールメッセージもないまま実行され、タブのフリーズや暴走するレンダーとしてしか表面化しません。クラスコンポーネントの #185 エラーはこれとは異なり、両方の環境で throw されます。

DevTools for the frontend

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.