中級11分で読める

【解決】useEffectの無限ループを止める:原因別5パターンの修正方法

useEffectが無限ループする原因を5パターンに分けて解説します。依存配列の誤り・オブジェクト参照・関数・setStateの連鎖など、よくあるパターンと具体的な修正コードを示します。

useEffectが無限ループしているとき、ブラウザはフリーズするかコンソールが同じログで溢れる。どこかで「state変更 → 再レンダリング → useEffect実行 → state変更」のサイクルが止まらなくなっている。

原因ごとにパターンが違うので、それぞれ見ていく。

Reactが依存配列を比較するしくみを先に知っておく

修正コードを見る前に、1点だけ理解しておきたいことがある。

Reactはuseeffectを「実行すべきか」の判断を、依存配列の値の変化で判断する。比較には Object.is() が使われる。

Object.is(1, 1)           // true  → 変化なし → useEffectは実行しない
Object.is('a', 'a')       // true  → 変化なし → useEffectは実行しない
Object.is({}, {})         // false → 変化あり → useEffectを実行する ← ここが罠
Object.is([], [])         // false → 変化あり → useEffectを実行する ← ここも罠

オブジェクトと配列は、中身が同じでも毎回のレンダリングで「新しいもの」として作られる。参照先が違うから Object.is()false を返す。これが多くの無限ループの根本原因だ。

パターン1:依存配列に更新するstateを入れている

// ❌ count を依存配列に入れながら count を更新している
function Bad() {
  const [count, setCount] = useState(0)

  useEffect(() => {
    setCount(count + 1)  // count を更新する
  }, [count])            // count が変わるとuseEffectが実行される → ループ
}

count を依存配列に入れる必要があるのは「countの最新値をuseEffect内で使いたいから」だ。でも count + 1 の計算に使うだけなら、「前の値から計算する」更新関数を使えば count に依存せずに済む。

// ✅ 更新関数(prev => ...)を使い、依存配列からcountを外す
function Good() {
  const [count, setCount] = useState(0)

  useEffect(() => {
    setCount((prev) => prev + 1)  // count の最新値を参照しない
  }, [])  // 1回だけ実行
}

パターン2:依存配列にオブジェクトを入れている

冒頭で説明した「オブジェクトは毎回別の参照になる」問題がそのまま起きる。

// ❌ options はレンダリングのたびに新しいオブジェクトが作られる
function Bad({ userId }: { userId: number }) {
  const [profile, setProfile] = useState(null)
  const options = { userId, fields: ['name', 'email'] }  // ← 毎回新しいオブジェクト

  useEffect(() => {
    fetchProfile(options).then(setProfile)
  }, [options])  // 毎回「変化あり」と判定 → ループ
}

解決策は「プリミティブな値を依存配列に入れる」か「useMemoで参照を固定する」かの2つだ。

// ✅ パターンA: プリミティブな値(number, string)を依存配列に入れる
useEffect(() => {
  fetchProfile({ userId, fields: ['name', 'email'] }).then(setProfile)
}, [userId])  // numberはObject.is()で正しく比較される

// ✅ パターンB: どうしてもオブジェクトが必要ならuseMemoで参照を固定する
const options = useMemo(() => ({ userId, fields: ['name', 'email'] }), [userId])

useEffect(() => {
  fetchProfile(options).then(setProfile)
}, [options])  // useMemoで固定されているので、userIdが変わるときだけ変化する

パターン3:依存配列に関数を入れている

関数もオブジェクトと同じで、毎回のレンダリングで新しい参照になる。

// ❌ 親が毎回新しい関数を渡しているので、子のuseEffectが毎回実行される
function Parent() {
  const [data, setData] = useState(null)
  return <Child onLoad={setData} />  // setDataは安定しているが...
}

function Child({ onLoad }: { onLoad: (data: unknown) => void }) {
  useEffect(() => {
    fetch('/api/data').then(r => r.json()).then(onLoad)
  }, [onLoad])  // 親が新しい関数を渡すたびに実行 → ループ
}
// ✅ useCallbackで関数の参照を固定する
function Parent() {
  const [data, setData] = useState(null)

  const handleLoad = useCallback((incoming: unknown) => {
    setData(incoming)
  }, [])  // 依存なし → 参照が変わらない

  return <Child onLoad={handleLoad} />
}

ただし useCallback を多用するとコードが複雑になる。「propsのコールバックを依存配列に入れる必要があるか」を先に疑い、入れなくて済む設計にできないか考えるのが先だ。

パターン4:依存配列を書き忘れている

シンプルなミス。依存配列そのものを省略すると、毎回のレンダリング後にuseEffectが実行される。

// ❌ 依存配列がない → 毎レンダリング後に実行される
function Bad() {
  const [count, setCount] = useState(0)

  useEffect(() => {
    setCount(count + 1)  // state更新 → レンダリング → useEffect実行 → ループ
  })  // ← 依存配列がない
}

// ✅ 必ず依存配列を書く
useEffect(() => {
  // 処理
}, [])  // または必要な値を列挙

ESLintの react-hooks/exhaustive-deps ルールを有効にしておくと、依存配列の漏れや省略を指摘してくれる。

パターン5:useEffect内で更新したstateを依存配列に入れている

パターン1の変形。items を更新しながら items を依存配列に入れると、更新のたびに実行されてループする。

// ❌ items を更新して、items を依存配列に
function Bad() {
  const [items, setItems] = useState<string[]>([])

  useEffect(() => {
    setItems([...items, 'new item'])
  }, [items])  // items が変わる → useEffect実行 → items が変わる → ループ
}

更新が1回だけなら空配列を使う。繰り返しが必要なら「別のstate」をトリガーにする。

// ✅ 初期データのセットなら空配列で1回だけ
useEffect(() => {
  setItems(['initial item'])
}, [])

// ✅ ボタン押下など明示的なトリガーから更新する
function Good() {
  const [items, setItems] = useState<string[]>([])
  const [trigger, setTrigger] = useState(0)

  useEffect(() => {
    setItems((prev) => [...prev, 'new item'])
  }, [trigger])  // trigger が変わったときだけ実行

  return <button onClick={() => setTrigger((t) => t + 1)}>追加</button>
}

デバッグ:どこでループしているか特定する

どのuseEffectが原因かわからないとき、まずconsole.logを仕込む。

useEffect(() => {
  console.log('実行回数カウント', { someState })
}, [someState])

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

依存配列の値が変わり続けているかどうかは、useRefで前回値と比較すると確認できる。

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

React DevToolsのProfilerタブでレンダリングの連鎖を可視化すると、どのコンポーネントが何度も再レンダリングされているかと、その原因(「state changed」など)が一目でわかる。

このエラーと向き合うための1つの考え方

useEffectの依存配列に「オブジェクト」「配列」「関数」を直接入れない。

これらは毎回のレンダリングで新しい参照になるため、依存配列に入れると「毎回変化した」と判定される。プリミティブな値(number, string, boolean)に分解して渡すか、useMemo/useCallbackで参照を固定するのが基本的な対処だ。