Debugging React useEffect Infinite Loops: Common Causes and Fixes

May 14, 2026 7 min read 18 views
Stylized infinite loop symbol made of circuit lines on a soft gradient background, representing a React useEffect debugging concept

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 useEffect re-runs and what controls that behavior
  • The five most common causes of infinite loops
  • Concrete code fixes for each cause
  • How to use useCallback and useMemo to 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 dependency

Every 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 mount

Only 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 loop

Because 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 stable

Cause 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-hooks

Add 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-deps without 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:

  1. 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.
  2. Are any objects or arrays created inside the component body listed as dependencies? Memoize them or move them outside.
  3. Are any functions listed as dependencies? Inline them or wrap them in useCallback.
  4. Do any props passed from a parent change reference on every render? Stabilize at the source.
  5. 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-hooks with exhaustive-deps set to warn in every React project you work on.
  • Audit any existing useEffect that takes an object or function as a dependency and apply the useMemo or useCallback fix where needed.
  • For every async effect you write, add an AbortController cleanup 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 save

Comments (0)

No comments yet. Be the first!

Leave a Comment

Sign in to comment with your profile.

πŸ“¬ Weekly Newsletter

Stay ahead of the curve

Get the best programming tutorials, data analytics tips, and tool reviews delivered to your inbox every week.

No spam. Unsubscribe anytime.