DEV Community

AttractivePenguin
AttractivePenguin

Posted on

How to Fix React useEffect Infinite Loops in 5 Minutes

How to Fix React useEffect Infinite Loops in 5 Minutes

The dependency array got you stuck? Here's exactly why your effect keeps running and how to fix it for good.


Why This Matters

If you've worked with React hooks for more than five minutes, you've probably encountered the dreaded infinite loop. You add a useEffect, your component re-renders endlessly, your browser tab freezes, and you're left wondering what went wrong.

The useEffect hook is powerful, but its dependency array is one of the most misunderstood features in React. A wrong dependency can trigger an infinite re-render cycle that crashes your app and frustrates your users.

The good news? Once you understand the root cause, fixing it takes less than five minutes. This guide will show you exactly how to diagnose and solve every common infinite loop pattern.


Understanding the Problem

What Causes Infinite Loops?

The useEffect hook runs when any value in its dependency array changes. React compares dependencies by reference for objects and functions, and by value for primitives.

Here's the critical insight: every time a component re-renders, it creates new references for objects and functions defined inside it.

function MyComponent() {
  // These are NEW references on every render
  const config = { apiUrl: '/api' };      // New object each render
  const fetchData = () => { /* ... */ };  // New function each render

  useEffect(() => {
    fetchData();
  }, [fetchData]); // Effect runs every render!

  return <div>Content</div>;
}
Enter fullscreen mode Exit fullscreen mode

This creates a cycle:

  1. Component renders → creates new fetchData reference
  2. React sees dependency "changed" → runs useEffect
  3. useEffect updates state → triggers re-render
  4. Back to step 1 ♻️

The Three Common Culprits

Culprit #1: Unstable Object References

The Problem:

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  // New object every render!
  const options = { 
    headers: { 'Content-Type': 'application/json' }
  };

  useEffect(() => {
    fetch(`/api/users/${userId}`, options)
      .then(res => res.json())
      .then(setUser);
  }, [options]); // Infinite loop!

  return <div>{user?.name}</div>;
}
Enter fullscreen mode Exit fullscreen mode

Why it loops: options is a new object on every render. React's dependency comparison sees it as "changed" every time.

The Fix with useMemo:

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  // Memoize the object - stable reference!
  const options = useMemo(() => ({ 
    headers: { 'Content-Type': 'application/json' }
  }), []); // Empty deps = never changes

  useEffect(() => {
    fetch(`/api/users/${userId}`, options)
      .then(res => res.json())
      .then(setUser);
  }, [userId, options]); // Now stable

  return <div>{user?.name}</div>;
}
Enter fullscreen mode Exit fullscreen mode

Culprit #2: Unstable Function References

The Problem:

function TodoList() {
  const [todos, setTodos] = useState([]);

  // New function every render!
  const fetchTodos = async () => {
    const response = await fetch('/api/todos');
    const data = await response.json();
    setTodos(data);
  };

  useEffect(() => {
    fetchTodos();
  }, [fetchTodos]); // Infinite loop!

  return <TodoItems todos={todos} />;
}
Enter fullscreen mode Exit fullscreen mode

Why it loops: Same issue - new function reference on every render.

The Fix with useCallback:

function TodoList() {
  const [todos, setTodos] = useState([]);

  // Memoize the function - stable reference!
  const fetchTodos = useCallback(async () => {
    const response = await fetch('/api/todos');
    const data = await response.json();
    setTodos(data);
  }, []); // No dependencies = stable forever

  useEffect(() => {
    fetchTodos();
  }, [fetchTodos]); // Now stable

  return <TodoItems todos={todos} />;
}
Enter fullscreen mode Exit fullscreen mode

Culprit #3: State Updates Without Conditions

The Problem:

function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    // Always updates state → always triggers re-render!
    setCount(count + 1);
  }, [count]); // Infinite loop!

  return <div>Count: {count}</div>;
}
Enter fullscreen mode Exit fullscreen mode

Why it loops: Effect runs → updates state → component re-renders → effect runs again.

The Fix - Add a Condition:

function Counter() {
  const [count, setCount] = useState(0);
  const [initialized, setInitialized] = useState(false);

  useEffect(() => {
    if (!initialized) {
      setCount(1); // Set initial value
      setInitialized(true);
    }
  }, [initialized]); // Runs once, then stops

  return <div>Count: {count}</div>;
}
Enter fullscreen mode Exit fullscreen mode

Better Fix - Use Proper Initialization:

function Counter({ initialCount = 0 }) {
  // Initialize state once with prop or default
  const [count, setCount] = useState(initialCount);

  // No effect needed for initialization!

  return <div>Count: {count}</div>;
}
Enter fullscreen mode Exit fullscreen mode

The Quick Reference Fix Guide

Problem Pattern Symptom Solution
Object in deps Re-renders on every state change useMemo(() => obj, [deps])
Function in deps Infinite loop immediately useCallback(() => fn, [deps])
Effect sets state Loop without stopping Add conditional guard
Effect calls setter Loop with dependency Use functional update setVal(prev => prev + 1)
Missing dependency Stale data Add the dependency, then fix stability

Real-World Scenarios

Scenario 1: Fetching Data on Mount

Wrong:

function Dashboard() {
  const [data, setData] = useState(null);

  useEffect(() => {
    fetchData().then(setData);
  }); // Missing deps array = runs every render!
}
Enter fullscreen mode Exit fullscreen mode

Right:

function Dashboard() {
  const [data, setData] = useState(null);

  useEffect(() => {
    let cancelled = false;

    fetchData().then(result => {
      if (!cancelled) setData(result);
    });

    return () => { cancelled = true; };
  }, []); // Empty array = runs once on mount
}
Enter fullscreen mode Exit fullscreen mode

Scenario 2: Fetching Data When Prop Changes

Wrong:

function UserPosts({ userId }) {
  const [posts, setPosts] = useState([]);
  const fetchPosts = () => {
    fetch(`/api/users/${userId}/posts`)
      .then(res => res.json())
      .then(setPosts);
  };

  useEffect(() => {
    fetchPosts();
  }, [fetchPosts]); // fetchPosts is unstable!
}
Enter fullscreen mode Exit fullscreen mode

Right:

function UserPosts({ userId }) {
  const [posts, setPosts] = useState([]);

  useEffect(() => {
    let cancelled = false;

    fetch(`/api/users/${userId}/posts`)
      .then(res => res.json())
      .then(data => {
        if (!cancelled) setPosts(data);
      });

    return () => { cancelled = true; };
  }, [userId]); // Only userId in deps - primitive, stable
}
Enter fullscreen mode Exit fullscreen mode

Scenario 3: Complex Object Dependencies

Wrong:

function SearchResults({ query, filters }) {
  const [results, setResults] = useState([]);

  const searchParams = { query, ...filters }; // New object each render!

  useEffect(() => {
    searchAPI(searchParams).then(setResults);
  }, [searchParams]); // Unstable!
}
Enter fullscreen mode Exit fullscreen mode

Right:

function SearchResults({ query, filters }) {
  const [results, setResults] = useState([]);

  useEffect(() => {
    let cancelled = false;

    searchAPI({ query, ...filters })
      .then(data => {
        if (!cancelled) setResults(data);
      });

    return () => { cancelled = true; };
  }, [query, filters]); // Primitives or stable references
}
Enter fullscreen mode Exit fullscreen mode

Or with useMemo for complex cases:

const searchParams = useMemo(() => ({ 
  query, 
  ...filters 
}), [query, filters]); // Only recreates when inputs change

useEffect(() => {
  searchAPI(searchParams).then(setResults);
}, [searchParams]); // Now stable!
Enter fullscreen mode Exit fullscreen mode

FAQ

Q: Should I just disable the ESLint warning?

A: No. The react-hooks/exhaustive-deps warning exists for a reason. It catches real bugs. Instead of disabling it, fix the underlying issue.

Q: Can I use an empty dependency array to run effect once?

A: Yes, for truly mount-only effects. But be careful - if your effect uses props or state, you might have stale data. Consider if the effect really only needs to run once.

Q: What's the difference between useMemo and useCallback?

A:

  • useMemo memoizes a value (objects, arrays, computed results)
  • useCallback memoizes a function (callbacks, handlers)

They're actually related: useCallback(fn, deps) is equivalent to useMemo(() => fn, deps).

Q: My effect still loops even with useCallback. What now?

A: Check what's inside useCallback's dependency array. If it depends on unstable values, the callback itself becomes unstable. You might need to use refs or restructure your code.

Q: When should I use useRef instead?

A: Use refs when:

  • You need a mutable value that doesn't trigger re-renders
  • You're storing a DOM element reference
  • You need to access a value from a previous render without causing effects

Q: How do I debug dependency issues?

A: Use React DevTools Profiler to see what's causing re-renders. Console.log inside your effect to see how often it runs. The useWhyDidYouRender utility function can also help identify unnecessary re-renders.

// Debug helper
useEffect(() => {
  console.log('Effect ran with deps:', { /* your deps */ });
}, [dep1, dep2]);
Enter fullscreen mode Exit fullscreen mode

The Five-Minute Fix Checklist

Next time you hit an infinite loop, run through this checklist:

  1. Find the dependency array - What's in useEffect(() => {}, [deps])?
  2. Check for objects/functions - Are any created inside the component?
  3. Apply the right fix:
    • Object? → Wrap in useMemo
    • Function? → Wrap in useCallback
    • State update? → Add condition or use functional update
  4. Verify with DevTools - Check that re-renders stopped
  5. Clean up - Remove any console.logs you added for debugging

Troubleshooting Common Mistakes

Mistake: Forgetting to Include Dependencies

// Wrong - ESLint will warn you
useEffect(() => {
  fetchUser(userId);
}, []); // Missing userId!
Enter fullscreen mode Exit fullscreen mode
// Right
useEffect(() => {
  fetchUser(userId);
}, [userId]);
Enter fullscreen mode Exit fullscreen mode

Mistake: Inline Object in Dependency Array

// Wrong - creates new object each render
useEffect(() => {
  doSomething(config);
}, [{ apiUrl }]); // Inline object = infinite loop
Enter fullscreen mode Exit fullscreen mode
// Right
const config = useMemo(() => ({ apiUrl }), [apiUrl]);
useEffect(() => {
  doSomething(config);
}, [config]);
Enter fullscreen mode Exit fullscreen mode

Mistake: Not Cleaning Up Effects

// Wrong - can cause race conditions
useEffect(() => {
  fetchData().then(setData);
}, [id]);
Enter fullscreen mode Exit fullscreen mode
// Right - cleanup prevents stale data
useEffect(() => {
  let cancelled = false;

  fetchData().then(data => {
    if (!cancelled) setData(data);
  });

  return () => { cancelled = true; };
}, [id]);
Enter fullscreen mode Exit fullscreen mode

Conclusion

The useEffect infinite loop is one of the most common React bugs, but it's also one of the easiest to fix once you understand the pattern:

  1. Objects and functions are new references on every render
  2. React compares dependencies by reference (for objects/functions) or by value (for primitives)
  3. Use useMemo to stabilize objects and useCallback to stabilize functions
  4. Always include all dependencies - don't fight the ESLint rule

The five-minute fix workflow:

  1. Identify the unstable dependency
  2. Wrap it in useMemo or useCallback
  3. Add cleanup if fetching data
  4. Test and move on

Next time you see that spinning browser tab, you'll know exactly what to do.


Happy coding! Remember: the dependency array is your friend, not your enemy. It's there to help you write correct, predictable effects.

Top comments (0)