⏱️3 min read · 549 words
React useEffect Infinite Loop — Causes and Fixes (2026)
Symptom: Component re-renders endlessly. Browser slows to a crawl. Console shows repeated API calls or state updates.
📋 Table of Contents
- Root Cause
- Cause 1: Missing or Wrong Dependency Array
- Cause 2: Object or Array in Dependencies (New Reference Each Render)
- Cause 3: Function in Dependencies (New Reference Each Render)
- Cause 4: setState Inside Effect Without Condition
- Cause 5: Async in useEffect
- Debugging Infinite Loops
- React 19 Note (2026)
- Quick Fixes Summary
Root Cause
Infinite loops in useEffect happen when: the effect runs → causes a state/prop change → component re-renders → effect runs again → repeat forever.
Cause 1: Missing or Wrong Dependency Array
// BAD: No dependency array = runs after EVERY render
useEffect(() => {
setCount(count + 1); // Updates state → re-render → runs again → infinite loop
});
// BAD: Dependency on something that changes every render
useEffect(() => {
setCount(count + 1);
}, [count]); // count changes → effect runs → count changes → infinite loop
// GOOD: Empty array = runs once on mount
useEffect(() => {
fetchData(); // One-time data fetch
}, []);
// GOOD: Correct dependencies
useEffect(() => {
if (userId) fetchUserData(userId);
}, [userId]); // Only runs when userId changes
Cause 2: Object or Array in Dependencies (New Reference Each Render)
// BAD: New object created every render = effect runs every render
const options = { page: 1, limit: 10 }; // New reference each render!
useEffect(() => {
fetchData(options);
}, [options]); // options always "changed" → infinite loop
// FIX 1: Move object inside the effect (if no dependencies)
useEffect(() => {
const options = { page: 1, limit: 10 };
fetchData(options);
}, []);
// FIX 2: useMemo to stabilize the reference
const options = useMemo(() => ({
page: 1,
limit: 10
}), []); // Only creates new object if dependencies change
useEffect(() => {
fetchData(options);
}, [options]); // Now stable
// FIX 3: Use primitive values as dependencies
useEffect(() => {
fetchData({ page, limit });
}, [page, limit]); // Primitives compare by value, not reference
Cause 3: Function in Dependencies (New Reference Each Render)
// BAD: Function recreated every render
const fetchUser = () => { // New reference each render!
return fetch('/api/user');
};
useEffect(() => {
fetchUser();
}, [fetchUser]); // fetchUser always "changed" → infinite loop
// FIX: useCallback to stabilize the function reference
const fetchUser = useCallback(() => {
return fetch('/api/user');
}, []); // Stable reference
useEffect(() => {
fetchUser();
}, [fetchUser]); // Now stable
// FIX 2: Move the function inside the effect (simpler)
useEffect(() => {
const fetchUser = () => fetch('/api/user');
fetchUser();
}, []);
Cause 4: setState Inside Effect Without Condition
// BAD: Unconditional state update
useEffect(() => {
setData(processedData); // Always updates → re-render → runs again
}, [data]);
// GOOD: Only update if value actually changed
useEffect(() => {
const processed = processData(data);
if (processed !== prevProcessed) { // Check before updating
setProcessedData(processed);
}
}, [data]);
// GOOD: Use functional update to avoid stale closures
useEffect(() => {
setCount(prevCount => prevCount + 1); // Doesn't need count in deps
}, [someOtherDep]);
Cause 5: Async in useEffect
// BAD: async directly in useEffect
useEffect(async () => { // Don't do this
const data = await fetchData();
setData(data);
}, []);
// GOOD: Define async function inside
useEffect(() => {
let cancelled = false;
const fetchAsync = async () => {
try {
const data = await fetchData();
if (!cancelled) setData(data); // Prevent state update after unmount
} catch (err) {
if (!cancelled) setError(err);
}
};
fetchAsync();
return () => { cancelled = true; }; // Cleanup on unmount
}, []);
Debugging Infinite Loops
// Add this to find which dependency is changing:
useEffect(() => {
console.log('Effect ran');
}, [dep1, dep2, dep3]);
// Better: Use a custom hook to log changes
function useWhyDidYouUpdate(deps) {
const prevDepsRef = useRef(deps);
useEffect(() => {
const changes = deps.reduce((acc, dep, i) => {
if (dep !== prevDepsRef.current[i]) {
acc.push({ index: i, from: prevDepsRef.current[i], to: dep });
}
return acc;
}, []);
if (changes.length) console.log('Dependency changes:', changes);
prevDepsRef.current = deps;
});
}
React 19 Note (2026)
React 19 introduced the React Compiler (formerly React Forget) which automatically memoizes components and values. If you’re on React 19+ with the compiler enabled, many of these issues are handled automatically. However, understanding the underlying cause is still essential for debugging.
Quick Fixes Summary
| Cause | Fix |
|---|---|
| Object/array dependency | Use primitive deps or useMemo |
| Function dependency | useCallback or move inside effect |
| State update in effect | Add condition before setting state |
| Missing cleanup | Return cleanup function from effect |
| Async race condition | Add cancelled flag, return cleanup |
📚 You might also like
🔗 Share this article



✍️ Leave a Comment