【解決】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で参照を固定するのが基本的な対処だ。