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>;
}
This creates a cycle:
- Component renders → creates new
fetchDatareference - React sees dependency "changed" → runs
useEffect -
useEffectupdates state → triggers re-render - 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>;
}
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>;
}
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} />;
}
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} />;
}
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>;
}
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>;
}
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>;
}
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!
}
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
}
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!
}
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
}
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!
}
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
}
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!
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:
-
useMemomemoizes a value (objects, arrays, computed results) -
useCallbackmemoizes 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]);
The Five-Minute Fix Checklist
Next time you hit an infinite loop, run through this checklist:
-
Find the dependency array - What's in
useEffect(() => {}, [deps])? - Check for objects/functions - Are any created inside the component?
-
Apply the right fix:
- Object? → Wrap in
useMemo - Function? → Wrap in
useCallback - State update? → Add condition or use functional update
- Object? → Wrap in
- Verify with DevTools - Check that re-renders stopped
- 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!
// Right
useEffect(() => {
fetchUser(userId);
}, [userId]);
Mistake: Inline Object in Dependency Array
// Wrong - creates new object each render
useEffect(() => {
doSomething(config);
}, [{ apiUrl }]); // Inline object = infinite loop
// Right
const config = useMemo(() => ({ apiUrl }), [apiUrl]);
useEffect(() => {
doSomething(config);
}, [config]);
Mistake: Not Cleaning Up Effects
// Wrong - can cause race conditions
useEffect(() => {
fetchData().then(setData);
}, [id]);
// Right - cleanup prevents stale data
useEffect(() => {
let cancelled = false;
fetchData().then(data => {
if (!cancelled) setData(data);
});
return () => { cancelled = true; };
}, [id]);
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:
- Objects and functions are new references on every render
- React compares dependencies by reference (for objects/functions) or by value (for primitives)
- Use
useMemoto stabilize objects anduseCallbackto stabilize functions - Always include all dependencies - don't fight the ESLint rule
The five-minute fix workflow:
- Identify the unstable dependency
- Wrap it in
useMemooruseCallback - Add cleanup if fetching data
- 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)