On this page

Engineering6 min read

Fix React useEffect Infinite Loop: 4 Causes and Fixes

React useEffect infinite loops happen when your effect modifies a value in its own dependency array. Here are the 4 patterns that cause it and the exact fix for each: objects, functions, state, missing deps.

reactuseeffectinfinite-loopjavascripthooks

A useEffect infinite loop happens when the effect modifies a value that is also in its dependency array. The effect runs, changes the value, React sees the change, runs the effect again, and the cycle continues until React throws a maximum update depth error.

javascript
useEffect(() => {
  setCount(count + 1)
}, [count])

This runs forever. Four patterns cause this. Each has a different fix.

The Error Message

Error: Too many re-renders. React limits the number of renders to prevent an infinite loop.

Or in the browser console:

Warning: 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.

Pattern 1: State Updated by the Effect Is in Its Own Deps

javascript
const [data, setData] = useState([])

useEffect(() => {
  setData([...data, newItem])
}, [data])

data changes, effect runs, data changes again. Use the functional update form instead. It does not need data in the dependency array:

javascript
useEffect(() => {
  setData(prev => [...prev, newItem])
}, [])

Pattern 2: Object or Array as Dependency

javascript
const filters = { category: 'books', sort: 'price' }

useEffect(() => {
  fetchProducts(filters)
}, [filters])

Objects and arrays are compared by reference, not value. { category: 'books' } creates a new object on every render. React sees a new reference, treats it as a changed dependency, and runs the effect again.

If the object is static, move it outside the component:

javascript
const DEFAULT_FILTERS = { category: 'books', sort: 'price' }

function ProductList() {
  useEffect(() => {
    fetchProducts(DEFAULT_FILTERS)
  }, [])
}

If the values are dynamic, use primitives as dependencies instead of the whole object:

javascript
const [category, setCategory] = useState('books')
const [sort, setSort] = useState('price')

useEffect(() => {
  fetchProducts({ category, sort })
}, [category, sort])

Or memoize the object with useMemo so the reference only changes when the inputs change:

javascript
const filters = useMemo(
  () => ({ category, sort }),
  [category, sort]
)

useEffect(() => {
  fetchProducts(filters)
}, [filters])

Pattern 3: Function as Dependency

javascript
function fetchData() {
  return fetch('/api/data').then(r => r.json())
}

useEffect(() => {
  fetchData().then(setData)
}, [fetchData])

Functions defined inside the component get a new reference on every render. Same problem as objects.

Move the function inside the effect if it does not depend on anything external:

javascript
useEffect(() => {
  function fetchData() {
    return fetch('/api/data').then(r => r.json())
  }
  fetchData().then(setData)
}, [])

Or wrap in useCallback if the function needs to be reused elsewhere:

javascript
const fetchData = useCallback(() => {
  return fetch(`/api/data?userId=${userId}`).then(r => r.json())
}, [userId])

useEffect(() => {
  fetchData().then(setData)
}, [fetchData])

Pattern 4: Missing Dependency Array

javascript
useEffect(() => {
  document.title = `Items: ${items.length}`
})

No dependency array means the effect runs after every single render. Add one:

javascript
useEffect(() => {
  document.title = `Items: ${items.length}`
}, [items.length])

Note: A useEffect with no dependency array is almost never intentional. Always ask when the effect should run. Only once on mount means []. When something changes means [thatThing]. After every render means no array, which is rarely correct and usually a bug.

How to Diagnose a Loop

Add a ref counter to confirm the loop and find the culprit:

javascript
const renderCount = useRef(0)

useEffect(() => {
  renderCount.current++
  console.log(`Effect ran ${renderCount.current} times`)
}, [yourDeps])

If the count climbs fast, the loop is confirmed. Then log each dependency individually:

javascript
useEffect(() => {
  console.log('deps:', { depA, depB })
}, [depA, depB])

The dep that logs a new value on every render is the one causing the loop. React DevTools Profiler also shows re-render frequency. A component re-rendering 50+ times per second is a reliable signal.

Quick Reference

CauseFix
State updated by effect is in its own depsUse functional state update: setState(prev => ...)
Object or array in depsUse primitives or useMemo
Function in depsMove inside effect or wrap in useCallback
No dependency arrayAdd [] or specific deps

FAQ

Q: ESLint keeps telling me to add a dependency but adding it causes a loop. What do I do?

A: The lint rule is correct that the dep is missing, but the real fix is restructuring so the dep is stable. For objects and functions, that means useMemo or useCallback. For state, that means the functional update form. Disabling the lint rule with eslint-disable hides the symptom without fixing the cause.

Q: Can I use an empty dependency array for an effect that reads state?

A: You can, but the effect will only ever see the initial state value due to closure. Use the functional update form for state updates, or restructure so the effect does not need to read the current value directly.


For complex components where the loop is not obvious, multiple effects, or shared state across hooks, paste the component into DebugAI and it will read the full hook chain and identify which effect-dep pair is cycling.

Debug faster starting today.

Free VS Code extension. 10 sessions/day. No credit card.

Install Free →

Related Posts

Engineering

GitHub Copilot Just Changed Its Pricing. What Developers Need to Know

5 min read

Engineering

Why Your AI Agent Harness Fails at Debugging (And How to Fix It)

5 min read

← All posts