Debugging React useEffect Infinite Loops: Common Causes and Fixes
Your browser tab is frozen, the console is flooding with requests, and React is re-rendering your component hundreds of times per second. You have a useEffect infinite loop. This is one of the most disorienting bugs in React development because the symptom β a locked browser β hides the actual cause buried inside your dependency array.
The good news is that infinite loops in useEffect follow a small set of predictable patterns. Once you recognize the patterns, the fixes are straightforward.
What you'll learn
- Why
useEffectre-runs and what controls that behavior - The five most common causes of infinite loops
- Concrete code fixes for each cause
- How to use
useCallbackanduseMemoto stabilize dependencies - A mental checklist to audit your effects before they hit production
Prerequisites
You should be comfortable with React function components and have a basic understanding of hooks. The examples use React 18, but the patterns apply to any version that supports hooks (16.8+).
How useEffect decides to re-run
Before diagnosing the bug, you need a clear mental model of how useEffect works. React runs the effect after every render by default. To limit when it runs, you pass a dependency array as the second argument.
useEffect(() => {
// runs after every render where count changed
}, [count]);React compares each dependency value from the previous render to the current render using Object.is (strict equality). If any value is different, the effect fires again. If that effect causes a state update, the component re-renders, and React checks the dependencies again. When a dependency always appears different across renders, you get a loop.
Cause 1: Updating state directly inside the effect
The most obvious cause is also the most common one for beginners. You update a piece of state inside an effect, and that same state is listed as a dependency.
// BAD β infinite loop
const [count, setCount] = useState(0);
useEffect(() => {
setCount(count + 1); // updates state
}, [count]); // count is the dependencyEvery time the effect runs, count changes. That triggers a re-render. React sees count changed, runs the effect again. Repeat forever.
Fix: If you need the previous value to compute the next one, use the functional updater form and remove the dependency entirely.
// GOOD
useEffect(() => {
setCount(prev => prev + 1);
}, []); // runs once on mountOnly use this pattern when you genuinely want to run something once on mount. If you need the effect to respond to external data changes, the architecture needs a rethink β the state update probably belongs in an event handler, not an effect.
Cause 2: Objects and arrays as dependencies
This is the cause that trips up experienced developers. JavaScript compares objects by reference, not by value. A new object literal looks different to Object.is even if its contents are identical.
// BAD β options is recreated on every render
const options = { method: 'GET', timeout: 5000 };
useEffect(() => {
fetchData(options);
}, [options]); // new reference every render = infinite loopBecause options is defined inside the component body, a fresh object is created on every render. React sees a different reference each time and re-runs the effect, which may update state, which triggers another render, and the loop begins.
Fix 1: Move static objects outside the component so they are created once.
// GOOD β defined outside, stable reference
const OPTIONS = { method: 'GET', timeout: 5000 };
function MyComponent() {
useEffect(() => {
fetchData(OPTIONS);
}, []); // no dependency needed
}Fix 2: Use useMemo when the object depends on props or state.
// GOOD β memoized, stable reference
const options = useMemo(() => ({
method: 'GET',
timeout: props.timeout,
}), [props.timeout]);
useEffect(() => {
fetchData(options);
}, [options]);Cause 3: Functions as dependencies
The same reference problem applies to functions. A function defined inside the component body gets a new reference on every render, so listing it as a dependency creates a loop.
// BAD
function MyComponent() {
const loadData = () => {
// fetch something
};
useEffect(() => {
loadData();
}, [loadData]); // new function reference every render
}Fix 1: Inline the function body directly inside the effect if it is only used there.
// GOOD
useEffect(() => {
// fetch logic lives here directly
fetch('/api/data').then(r => r.json()).then(setData);
}, []);Fix 2: Use useCallback to stabilize the function reference when it is reused elsewhere.
// GOOD
const loadData = useCallback(() => {
fetch('/api/data').then(r => r.json()).then(setData);
}, []); // only recreated when its own deps change
useEffect(() => {
loadData();
}, [loadData]); // now stableCause 4: Dependencies from context or parent props that change on every render
Sometimes the instability comes from outside your component. A parent component passes an object or function as a prop, recreating it on every render. Your effect lists that prop as a dependency, and the loop begins even though your component looks correct in isolation.
// Parent β BUG is here
function Parent() {
return <Child config={{ theme: 'dark' }} />; // new object every render
}
// Child
function Child({ config }) {
useEffect(() => {
applyConfig(config);
}, [config]); // config always looks new
}Fix: Stabilize the value in the parent before passing it down.
// Parent β fixed
const config = useMemo(() => ({ theme: 'dark' }), []);
return <Child config={config} />;Alternatively, destructure the primitive values you actually need in the child and depend on those instead of the whole object.
function Child({ config }) {
const { theme } = config;
useEffect(() => {
applyConfig(theme);
}, [theme]); // primitive β stable comparison
}Cause 5: setState called from an async effect without a cleanup
This pattern does not always cause an infinite loop, but it often causes rapid re-renders that look like one. You fire an async request inside an effect, the result updates state, and if the component re-renders while the request is still in flight, you get a second request on top of the first.
// Risky β no cleanup
useEffect(() => {
fetch('/api/data')
.then(r => r.json())
.then(data => setData(data)); // may run after unmount
}, [query]);Fix: Use an AbortController to cancel the previous request before firing a new one.
// GOOD
useEffect(() => {
const controller = new AbortController();
fetch('/api/data', { signal: controller.signal })
.then(r => r.json())
.then(data => setData(data))
.catch(err => {
if (err.name !== 'AbortError') throw err;
});
return () => controller.abort(); // cleanup on re-run or unmount
}, [query]);Using the ESLint exhaustive-deps rule
React ships an ESLint plugin (eslint-plugin-react-hooks) that includes the exhaustive-deps rule. Enabling it will catch most of these problems at write time rather than at runtime.
npm install --save-dev eslint-plugin-react-hooksAdd it to your ESLint config:
{
"plugins": ["react-hooks"],
"rules": {
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn"
}
}The rule will warn you when a dependency is missing or when you include something unnecessary. Treat its warnings as a first-pass diagnostic, not a final authority. There are legitimate cases where you intentionally omit a dependency β but those cases are rare, and you should comment why.
Common pitfalls to watch for
- Disabling the lint rule to silence warnings. Adding
// eslint-disable-next-line react-hooks/exhaustive-depswithout a written explanation is almost always a mistake. Fix the root cause instead. - Wrapping everything in useCallback or useMemo as a default. These hooks have a cost. Use them to solve a specific stability problem, not as a blanket precaution.
- Putting an object in useState and mutating it. If you spread a new object into state and that object is a dependency of an effect, the reference changes even if the values are the same. Keep state as flat primitives where possible.
- Forgetting that custom hooks can contain effects. An infinite loop may originate inside a custom hook you imported. Trace the call stack in the console to find where the rapid re-renders begin.
A quick audit checklist
Before pushing any component that uses useEffect, run through this list:
- Does the effect update any state that is also listed as a dependency? If yes, consider the functional updater form or moving logic to an event handler.
- Are any objects or arrays created inside the component body listed as dependencies? Memoize them or move them outside.
- Are any functions listed as dependencies? Inline them or wrap them in
useCallback. - Do any props passed from a parent change reference on every render? Stabilize at the source.
- Does any async operation in the effect update state? Add a cleanup that aborts or ignores stale responses.
Wrapping up
Infinite loops in useEffect always come down to the same thing: a dependency that looks different to React on every render, which triggers the effect, which causes a re-render, which produces a new dependency reference, and so on. The patterns are finite and learnable.
Here are your concrete next steps:
- Enable
eslint-plugin-react-hookswithexhaustive-depsset towarnin every React project you work on. - Audit any existing
useEffectthat takes an object or function as a dependency and apply theuseMemooruseCallbackfix where needed. - For every async effect you write, add an
AbortControllercleanup as a default habit. - When you hit a loop you cannot immediately explain, open the React DevTools Profiler and watch which component is re-rendering and which state change is triggering it.
- Read the official React docs section on synchronizing with effects β the mental model they teach around "thinking in effects" resolves most dependency confusion at the design stage.
π€ Share this article
Sign in to saveRelated Articles
Comments (0)
No comments yet. Be the first!