The useEffect hook is one of React's most powerful features for managing side effects, but it's also one of the most frequently misunderstood. Infinite loops in useEffect happen when your effect triggers a state change that causes React to re-render, which triggers the effect again--creating an endless cycle that can crash your application or severely degrade performance. Understanding these patterns and how to fix them is essential for building performant React applications with Next.js and modern React patterns.
This guide covers the root causes of infinite loops, common patterns to avoid, and proven strategies for writing effects that execute exactly when intended.
By the Numbers
3
Main useEffect Phases
6+
Common Loop Patterns
5
Fix Strategies
Why useEffect Infinite Loops Happen
Understanding infinite loops requires grasping how useEffect actually works. The hook follows a predictable lifecycle: it runs after the component mounts, then runs again whenever its dependencies change, and finally cleans up when the component unmounts.
The critical insight is React's dependency comparison: it uses Object.is to compare each dependency value. This means object and function references matter--two objects with identical content are considered different if they have different references.
An infinite loop occurs when your effect both reads and modifies state or values that are in its own dependency array. The cycle goes like this:
- Component renders
- useEffect runs because dependencies changed (or it's first run)
- Effect updates state or modifies a dependency value
- State update causes re-render
- React compares dependencies and finds they're different
- Effect runs again → back to step 3
Breaking this cycle requires ensuring your effect either doesn't depend on values it modifies, or it modifies those values in a way that doesn't create a new reference.
The Dependency Array Mechanism
The dependency array is your primary control mechanism for when useEffect runs. Understanding its behavior is crucial:
| Array Configuration | Behavior |
|---|---|
| No array | Effect runs after every render |
| Empty array [] | Effect runs once on mount (and cleanup on unmount) |
| With dependencies | Effect runs when any dependency changes |
Key rule: If your effect references any value from the component scope (state, props, functions, objects), that value must be in the dependency array. This includes:
- State values (
count,data,user) - Props (
props.filter,props.onSubmit) - Functions defined in component scope
- Objects created inline or in component body
- Variables imported from modules (if used in effect)
Missing dependencies trigger React's exhaustive-deps lint rule for good reason--it's often the first indicator of a potential infinite loop.
Common Infinite Loop Patterns
Recognizing these patterns will help you identify and fix loops before they cause problems in production.
Pattern 1: State Update Without Proper Dependencies
The most common infinite loop occurs when an effect updates state that's also in its dependency array:
// BROKEN - Creates an infinite loop
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
setCount(count + 1); // Updates count → re-render → effect runs again
}, [count]); // count is in dependencies
return <div>{count}</div>;
}
The fix: Use functional updates when you don't need the current state value:
// FIXED - Uses functional update
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
if (count < 5) {
setCount(c => c + 1); // Functional update, no dependency on count
}
}, []); // Empty dependency array
return <div>{count}</div>;
}
Pattern 2: Object References in Dependencies
Objects are compared by reference, not by content. Creating objects inline or inside the component body creates a new reference on every render:
// BROKEN - New config object every render
function SearchComponent({ query }) {
const config = {
apiUrl: 'https://api.example.com',
timeout: 5000
};
useEffect(() => {
fetchData(config);
}, [config]); // New object = new reference = effect runs every render
return <div>{/* ... */}</div>;
}
The fix: Move objects outside the component or memoize them:
// FIXED - Stable reference
const config = {
apiUrl: 'https://api.example.com',
timeout: 5000
};
function SearchComponent({ query }) {
useEffect(() => {
fetchData(config);
}, [query]); // Only re-run when query changes
return <div>{/* ... */}</div>;
}
Pattern 3: Function Dependencies and Stale Closures
Functions defined inside components create new references on every render:
// BROKEN - New handleSubmit on every render
function Form({ onSubmit }) {
const handleSubmit = async (data) => {
await onSubmit(data);
};
useEffect(() => {
// Setup form with submit handler
setupForm(handleSubmit);
}, [handleSubmit]); // New function = effect runs every render
return <form>{/* ... */}</form>;
}
The fix: Memoize the function with useCallback:
// FIXED - Stable function reference
function Form({ onSubmit }) {
const handleSubmit = useCallback(async (data) => {
await onSubmit(data);
}, [onSubmit]);
useEffect(() => {
setupForm(handleSubmit);
}, [handleSubmit]); // Only runs if handleSubmit actually changes
return <form>{/* ... */}</form>;
}
Pattern 4: Async Operations with State Updates
Data fetching that updates state can create loops when the state is also a dependency:
// BROKEN - Re-fetches whenever data changes
function DataList({ filter }) {
const [data, setData] = useState([]);
useEffect(() => {
fetchData(filter).then(result => {
setData(result); // Updates data → re-render → effect runs again
});
}, [filter, data]); // data in dependencies = loop
return <List items={data} />;
}
The fix: Don't include the updated state in dependencies:
// FIXED - Only depends on filter
function DataList({ filter }) {
const [data, setData] = useState([]);
useEffect(() => {
let cancelled = false;
fetchData(filter).then(result => {
if (!cancelled) {
setData(result);
}
});
return () => { cancelled = true; };
}, [filter]); // Only re-fetch when filter changes
return <List items={data} />;
}
The Stale Closure Problem
Stale closures are a related but distinct problem that often accompanies infinite loops. A stale closure occurs when an effect captures state values from a previous render and uses them in ways that conflict with current state.
What Are Stale Closures?
In JavaScript, closures capture variables at the time they're created. Inside useEffect, any variable referenced from the component scope is captured when the effect runs. If that variable changes but the effect doesn't re-run, the effect still uses the old value.
Example of stale closure behavior:
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
// This 'count' is from the first render - stale!
console.log('Count:', count);
}, 1000);
return () => clearInterval(timer);
}, []); // Empty dependency - effect never re-runs
return (
<button onClick={() => setCount(c => c + 1)}>
Count: {count}
</button>
);
}
The interval always logs the initial count value, even after clicks update the state.
The fix: Use functional updates or refs:
function Counter() {
const countRef = useRef(0);
const [count, setCount] = useState(0);
useEffect(() => {
countRef.current = count; // Always current
const timer = setInterval(() => {
console.log('Count:', countRef.current);
}, 1000);
return () => clearInterval(timer);
}, []);
return (
<button onClick={() => {
countRef.current += 1;
setCount(c => c + 1);
}}>
Count: {count}
</button>
);
}
Recognizing Stale Closures
Watch for these signs:
- Console logs showing outdated values
- Event handlers using old props or state
- Async operations completing with wrong data
setTimeoutorsetIntervalcallbacks with stale state
Fixing Infinite Loops: Strategies and Techniques
Strategy 1: Proper Dependency Array Configuration
The foundation of preventing infinite loops is correct dependency arrays. Be exhaustive:
// Every value the effect uses must be listed
useEffect(() => {
const width = window.innerWidth;
const height = window.innerHeight;
setDimensions({ width, height });
const handleResize = () => {
setDimensions({ width: window.innerWidth, height: window.innerHeight });
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []); // Dependencies: none - only runs on mount/unmount
Strategy 2: Functional State Updates
Use the functional form of state setters to avoid depending on current state:
// Instead of:
setCount(count + 1); // Depends on count
// Use:
setCount(c => c + 1); // No dependency needed
Strategy 3: Memoization with useCallback
Prevent unnecessary function recreation:
const fetchData = useCallback(async (id) => {
const result = await api.getById(id);
setData(result);
}, []); // Stable reference
useEffect(() => {
fetchData(currentId);
}, [fetchData, currentId]); // Only runs when fetchData or currentId changes
Strategy 4: Memoization with useMemo
Stable references for objects and computed values:
const options = useMemo(() => ({
sort: sortKey,
filter: filterValue,
limit: pageSize
}), [sortKey, filterValue, pageSize]); // Only recreates when dependencies change
useEffect(() => {
fetchWithOptions(options);
}, [options]); // Stable reference
Strategy 5: Splitting Effects
Separate concerns with different dependencies:
// Before: One effect with multiple concerns and dependencies
useEffect(() => {
trackPageView();
updateDocumentTitle();
logAnalytics();
}, [location.pathname, user.id, theme]); // Runs when ANY of these change
// After: Split effects with focused dependencies
useEffect(() => {
trackPageView();
logAnalytics();
}, [location.pathname, user.id]);
useEffect(() => {
updateDocumentTitle();
}, [location.pathname, documentTitle]);
useEffect(() => {
applyTheme(theme);
}, [theme]);
Strategy 6: Using useRef for Mutable Values
For values that change frequently but shouldn't trigger effect re-runs:
function SearchComponent() {
const [query, setQuery] = useState('');
const previousQuery = useRef('');
const [results, setResults] = useState([]);
useEffect(() => {
if (query !== previousQuery.current) {
searchAPI(query).then(data => {
setResults(data);
previousQuery.current = query;
});
}
}, [query]);
return (
<>
<input value={query} onChange={e => setQuery(e.target.value)} />
<ResultsList results={results} />
</>
);
}
Strategy 7: Proper Cleanup Functions
Prevent memory leaks and unintended side effects:
useEffect(() => {
const subscription = dataSource.subscribe(handleUpdate);
// Cleanup runs before effect runs again AND on unmount
return () => {
subscription.unsubscribe();
};
}, [dataSource]);
Performance Optimization Beyond Infinite Loops
Avoiding Unnecessary Effects
Modern React philosophy emphasizes minimizing effects. Ask yourself: does this really need to be in an effect?
Often unnecessary:
- Logging on every render
- Syncing state that can be derived during render
- Initializing values that could be set once
- Transforming data that could be computed inline
Better alternatives:
// Instead of:
const [formatted, setFormatted] = useState('');
useEffect(() => {
setFormatter(formatData(raw));
}, [raw]);
// Consider deriving during render:
const formatted = formatData(raw); // Computed every render
// Or memoizing expensive computations:
const formatted = useMemo(() => {
return formatData(raw);
}, [raw]);
Modern Data Fetching
Libraries like React Query (TanStack Query) or SWR handle data fetching with built-in caching, deduplication, and stale-while-revalidate patterns--reducing the need for manual useEffect data fetching:
// Instead of manual useEffect data fetching
import { useQuery } from '@tanstack/react-query';
function UserProfile({ userId }) {
const { data, isLoading, error } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId)
});
if (isLoading) return <Loading />;
if (error) return <Error />;
return <Profile user={data} />;
}
This approach eliminates entire categories of infinite loop bugs by handling dependency tracking and caching automatically. For more on building performant React applications, see our guide on JavaScript fundamentals and Node.js logging practices.
Debugging Infinite Loops
When you suspect an infinite loop, systematic debugging helps identify the cause quickly.
React DevTools Profiler
The Profiler tab shows which components are re-rendering and how often. Look for:
- Components re-rendering far more than expected
- Correlations between re-renders and state changes
- Effects running in quick succession
Console Logging Strategy
Add logging to understand what's triggering re-renders:
useEffect(() => {
console.log('Effect running', {
count,
dataId,
timestamp: Date.now()
});
// Your effect logic
return () => {
console.log('Effect cleanup', { count, timestamp: Date.now() });
};
}, [count, dataId]);
ESLint Rules
Enable and respect React's exhaustive-deps rule. It catches most dependency issues before they become runtime problems:
{
"rules": {
"react-hooks/exhaustive-deps": "error"
}
}
Common Debugging Pattern
- Add dependency logging to identify what's changing
- Check if changes are expected or unexpected
- For unexpected changes, trace back to what's causing them
- Apply appropriate fix (memoization, refactoring, or functional updates)
- Verify the fix resolves the issue without breaking functionality
Best Practices Summary
1. Always Include All Dependencies
The exhaustive-deps rule exists for a reason. If your effect uses it, list it.
2. Use Functional Updates
setCount(c => c + 1) instead of setCount(count + 1) to avoid depending on current state.
3. Memoize Functions and Objects
useCallback and useMemo create stable references that prevent unnecessary re-runs.
4. Split Effects by Concern Separate effects with different dependencies into their own hooks.
5. Use useRef for Mutable Values Values that change often but shouldn't trigger re-renders belong in refs.
6. Add Cleanup Functions Prevent memory leaks and ensure effects can be interrupted safely.
7. Consider Alternatives Not everything needs useEffect. State can often be derived during render or managed with specialized libraries like React Query for data fetching. For teams building complex React applications with AI-powered features, proper effect management becomes even more critical.
8. Test Your Effects Write tests that verify effects run the correct number of times with various dependency combinations.
Frequently Asked Questions
Why does my useEffect run twice in React 18?
React 18's Strict Mode intentionally double-mounts components in development to help you find side effects that aren't properly cleaned up. This isn't an infinite loop--it's a feature to help you catch bugs. Your cleanup function should handle the second unmount gracefully.
Should I use an empty dependency array for all effects?
No. An empty dependency array means the effect only runs once on mount. If your effect needs to respond to prop or state changes, those values must be in the dependencies. Empty arrays are appropriate for one-time setup like subscriptions or event listeners.
How do I fix an effect that depends on an object that changes often?
Consider if the entire object needs to be a dependency, or if individual properties would suffice. You might also use useMemo to stabilize the object reference, or refactor to pass individual values instead of the whole object.
What's the difference between useCallback and useMemo?
useCallback memoizes a function reference (returns the same function between renders). useMemo memoizes any computed value (returns the same value). Use useCallback for functions, useMemo for objects or expensive computations.
Can I ignore the exhaustive-deps lint rule?
Ignoring this rule should be rare and deliberate. If you truly understand why the rule is warning and have verified your effect is correct, you can suppress it--but this is often a sign of a design issue that should be addressed instead.
Sources
- LogRocket: How to solve the React useEffect Hook's infinite loop patterns - Comprehensive guide covering useCallback memoization, stale closures, and dependency array fixes
- LogRocket: 15 common useEffect mistakes to avoid in your React apps - Updated guide covering dependency array patterns, stale state, and performance optimization