Why useEffect Loops Forever and How to Fix the Dependency Array
- Author
- Vishal Maurya
- Published on
- Reading time
- 4 min read
Overview
The network tab fills with identical requests, the component flickers, or the browser tab freezes. The usual culprit is a useEffect that changes something it depends on, or depends on a value that is new on every render. This post explains the causes and the fixes.
1. How the Dependency Array Is Compared
React compares each dependency with Object.is after every render. If any differs from the previous value, the effect runs again. Primitives compare by value. Objects, arrays, and functions compare by reference, so a new {} created during render is "different" every time even if the contents are identical.
2. The Classic Loop: Set State Inside an Effect That Depends on It
import { useEffect, useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
setCount(count + 1); // changes count, which re-triggers the effect
}, [count]);
return <p>{count}</p>;
}
Each run updates count, causing a render, which changes the dependency, which runs the effect. Ask whether the effect should exist at all. If you are computing a value from props or state, compute it during render instead (see the next post on state and derived data). If you must update based on previous state, use the functional form and remove the state from the dependencies:
setCount((c) => c + 1);
3. Objects and Arrays Recreated Every Render
function Results({ query }: { query: string }) {
const [data, setData] = useState<unknown[]>([]);
const options = { q: query, limit: 10 }; // new object each render
useEffect(() => {
fetch(`/api/search?q=${options.q}&limit=${options.limit}`)
.then((r) => r.json())
.then(setData); // state update triggers render, creating a new options object
}, [options]);
return <pre>{JSON.stringify(data)}</pre>;
}
options is new on every render, so the effect re-fires and the state update causes another render. Fix it by depending on primitives:
useEffect(() => {
fetch(`/api/search?q=${encodeURIComponent(query)}&limit=10`)
.then((r) => r.json())
.then(setData);
}, [query]);
Alternatives: move constant objects outside the component, or stabilize values with useMemo when they genuinely need to be objects. Do not reach for JSON.stringify in the dependency array as a default; it hides the design problem and can be expensive.
4. Functions as Dependencies
A function defined in the component body is new each render. If an effect lists it as a dependency, it re-runs constantly. Options:
- Move the function inside the effect if only the effect uses it.
- Move it outside the component if it does not use props or state.
- Wrap it with
useCallbackif it must be shared, and make sure its own dependencies are stable.
useEffect(() => {
async function load() {
const res = await fetch(`/api/users/${userId}`);
setUser(await res.json());
}
load();
}, [userId]);
5. Do Not Silence the Linter
The react-hooks/exhaustive-deps rule exists because omitted dependencies produce stale closures. Suppressing it with a comment often turns an infinite loop into a different bug where the effect reads old values. Fix the cause: make the dependency stable, derive it, or restructure.
If an effect needs the latest value of something without re-running on its change, consider a ref updated separately, or check whether your React version offers useEffectEvent (it is relatively new, so read the docs for your version).
6. Always Clean Up
Missing cleanup does not usually create an infinite loop, but it creates duplicate listeners, overlapping requests, and race conditions that look like one:
useEffect(() => {
const controller = new AbortController();
fetch(`/api/items?page=${page}`, { signal: controller.signal })
.then((r) => r.json())
.then(setItems)
.catch((err) => {
if (err.name !== 'AbortError') setError(err);
});
return () => controller.abort();
}, [page]);
Also clear timers and remove event listeners in the returned function. In development with React Strict Mode, effects run, clean up, and run again once on mount. That is intentional and is designed to expose missing cleanup, not a loop.
7. A Debugging Routine
- Log inside the effect and in the render body to see which fires repeatedly.
- Log each dependency; compare with the previous render using a ref.
- Look in the React DevTools Profiler to see why a component rendered.
- Check parent components: a parent that recreates a prop object every render can trigger the child's effect.
Conclusion
Infinite effect loops come from effects that change their own inputs or depend on values recreated each render. Depend on primitives, use functional state updates, keep objects stable only when needed, clean up side effects, and question whether an effect is needed.
If you have a React codebase with performance problems or tricky effect bugs, I can help review it. Contact me.