初級11分で読める

【解決】Maximum update depth exceeded エラーの原因と修正方法

ReactのMaximum update depth exceededエラーを解説します。useEffectの無限ループ・setStateの連鎖・イベントハンドラの誤用など原因別に修正コードを示します。

Reactを書いていると、ある日突然コンソールがこのエラーで埋め尽くされることがある。

Error: Maximum update depth exceeded.
This can happen when a component calls setState inside useEffect,
but useEffect either doesn't have a dependency array,
or one of the dependencies changes on every render.

画面が真っ白になって、コンソールに同じエラーが何百行も流れる。最初に見たときは正直パニックになった。でもこのエラー、原因のパターンはそれほど多くない。落ち着いて一つずつ確認すれば必ず直せる。

何が起きているのか

このエラーが出るということは、コンポーネントが無限に再レンダリングされている。Reactは親切なことに、ある閾値を超えたところで「もうやめろ」とループを止めてくれる。それがこのエラー。

なぜ無限ループが起きるかというと、こういうサイクルに入ってしまうから。

stateが変わる → レンダリング → useEffectが実行される → stateが変わる → レンダリング → ...

この「state変更 → レンダリング → state変更」が止まらない。シンプルな話なんだけど、実際のコードだと気づきにくい。

原因1:useEffectに依存配列を書き忘れた

いちばん多いパターン。依存配列(第2引数)を省略すると、レンダリングのたびにuseEffectが実行される

// ❌ これは無限ループになる
function BadComponent() {
  const [count, setCount] = useState(0)

  useEffect(() => {
    setCount(count + 1)  // 実行 → stateが変わる → レンダリング → 実行 → ...
  })  // 依存配列がない

  return <div>{count}</div>
}

修正方法は2つある。

// ✅ 修正①:空の依存配列でマウント時だけ実行
useEffect(() => {
  setCount(1)
}, [])  // []を書く

// ✅ 修正②:関数型updateでcountを依存から外す
useEffect(() => {
  setCount(prev => prev + 1)  // prevを使えばcountに依存しなくていい
}, [])

個人的には②の書き方のほうが好きで、「前の値を使って更新する」パターンはバグが起きにくい気がしている。

原因2:オブジェクトや配列を依存配列に入れている

これが一番わかりにくい。Reactは依存配列の値を Object.is() で比較するんだけど、オブジェクトや配列は毎回のレンダリングで新しいオブジェクトが作られる。参照が違うから「変わった」と判定されて、useEffectが走る。

// ❌ optionsは毎回新しいオブジェクトになる
function BadComponent({ limit }: { limit: number }) {
  const [data, setData] = useState<string[]>([])
  const options = { limit }  // ← ここが問題。毎回別のオブジェクト

  useEffect(() => {
    fetchData(options).then(setData)
  }, [options])  // 毎回「変わった」と判定されてループする

  return <ul>{data.map(d => <li key={d}>{d}</li>)}</ul>
}

「え、同じ値なのに?」ってなるやつ。でもJavaScriptの仕様として {limit: 10} === {limit: 10}false なので仕方ない。

修正はプリミティブな値だけを依存に入れること。

// ✅ limitそのものを依存に入れる
useEffect(() => {
  fetchData({ limit }).then(setData)
}, [limit])  // numberはObject.isで正しく比較される

どうしてもオブジェクトを使いたい場合は useMemo でメモ化する。

// ✅ useMemoで参照を安定させる
const options = useMemo(() => ({ limit }), [limit])

関数も同じ問題が起きる。コンポーネント内で定義した関数は毎回新しい参照になるので、依存配列に入れると無限ループになる。これは useCallback で対処できる。ただ正直、たいていの場合は依存配列の整理で解決できるので、useCallback を使いすぎないほうがコードはシンプルに保てる。

原因3:レンダリング中にsetStateを呼んでいる

これは割と「やっちゃいけないやつだとわかってるけど書いてしまった」パターン。

// ❌ useEffectの外でsetStateを呼ぶ → 即再レンダリング → 無限ループ
function BadComponent() {
  const [count, setCount] = useState(0)

  setCount(count + 1)  // レンダリング中に呼んでいる

  return <div>{count}</div>
}

これは明らかなミスに見えるけど、「初期化処理をどこに書くか迷ったとき」や「条件によって初期値を変えたいとき」に誤ってやってしまいがち。

// ✅ イベントハンドラの中で呼ぶ
return <button onClick={() => setCount(c => c + 1)}>増やす</button>

// ✅ 初期化はuseStateの引数で行う
const [data, setData] = useState(() => {
  return JSON.parse(localStorage.getItem('data') ?? '[]')
  // この関数は最初の1回だけ実行される
})

初期化ロジックが複雑な場合は useState の関数形式(遅延初期化)が使える。これ、意外と知らない人が多いんじゃないかと思っている。

原因4:onClick={fn()} と書いてしまった

地味だけどよくある凡ミス。

// ❌ fn() と書くとレンダリング中に即実行される
<button onClick={handleClick()}>クリック</button>

// ✅ () を外す。関数の「参照」を渡す
<button onClick={handleClick}>クリック</button>
<button onClick={() => setCount(c => c + 1)}>クリック</button>

handleClick() は「handleClickを呼び出した結果(たいていはundefined)」をonClickに渡している。なのでレンダリング中に実行されてしまう。括弧ひとつの差で挙動が全然違う。

原因5:カスタムHookが毎回新しいオブジェクトを返している

カスタムHookを作り始めた段階で踏むことが多いバグ。

// ❌ 呼ぶたびに新しいオブジェクトが生成される
function useConfig() {
  return { theme: 'dark', lang: 'ja' }
}

// このconfigを依存配列に入れると無限ループになる
const config = useConfig()
useEffect(() => { ... }, [config])
// ✅ useMemoで安定させる
function useConfig() {
  return useMemo(() => ({ theme: 'dark', lang: 'ja' }), [])
}

カスタムHookを公開する立場なら、戻り値がオブジェクトや配列のときは基本的にメモ化しておいたほうが使う側が楽。

余談:React 18のStrictModeについて

開発環境でuseEffectが2回実行されることがある。これはReact 18のStrictModeが意図的にやっていること(マウント→アンマウント→再マウントで、クリーンアップが正しく書かれているかチェックしている)で、バグではない。

本物の無限ループとは違って、回数が有限(2回)で止まる。コンソールに同じログが2つ出るだけなら、たいていこっち。

// クリーンアップをちゃんと書けばStrictModeでも問題ない
useEffect(() => {
  let cancelled = false
  fetch('/api/data')
    .then(r => r.json())
    .then(data => {
      if (!cancelled) setData(data)
    })
  return () => { cancelled = true }
}, [])

調査するときの手順

「どのuseEffectが原因かわからない」ときは、React DevToolsのProfilerタブを開いてレコーディングする。どのコンポーネントが何度も再レンダリングされているか、なぜ("state changed"など)が一目でわかる。

手軽に調べるなら console.log でいい。

useEffect(() => {
  console.log('実行されました', { count, options })
}, [count, options])

毎フレーム流れているなら依存配列の問題。特定の操作後に急増するならイベントハンドラ周りを疑う。

依存配列の値が変わり続けているかどうかを確認したいときは、こういう書き方もできる。

const prevRef = useRef(options)
if (prevRef.current !== options) {
  console.log('optionsが変わった', prevRef.current, '→', options)
  prevRef.current = options
}

まとめ

エラーが出たら、まずここだけ確認すれば8割くらいは解決する。

  • useEffect[] を忘れていないか
  • 依存配列にオブジェクト・配列・関数を直接入れていないか
  • onClick={fn()} のように即時実行していないか

残り2割は useMemo / useCallback の使い方とカスタムHookの設計になってくる。最初から完璧に書こうとするより、エラーが出てから直すほうが理解が深まる気がしているので、とりあえず動かしてみるのがいいと思っている。