Quick Answer
useEffect : Fix Common React Problems 2025 Master React's useEffect hook with practical solutions to infinite loops, cleanup issues, dependency arrays, and more.
useEffect Guide: Fix Common React Problems 2025
useEffect is React's most misunderstood hook. Every React developer has spent hours debugging infinite loops, stale closures, and mysterious re-renders.
After years of building React applications and helping others debug their useEffect issues, here's the complete guide I wish I had when I started.
Quick Answer#
Common useEffect problems and solutions:
- Infinite loops → Check dependency array, avoid object/array dependencies
- Stale closures → Include all dependencies, use functional updates
- Cleanup not running → Return cleanup function, handle unmounting
- Too many re-renders → Memoize dependencies, use refs for values that shouldn't trigger effects
- Race conditions → Use cleanup to cancel async operations
Rule of thumb: If a value is used inside useEffect, it should be in the dependency array (unless you have a specific reason to exclude it).
Understanding useEffect Basics#
What useEffect Does#
useEffect lets you perform side effects in functional components. Side effects are things like:
- Fetching data
- Setting up subscriptions
- Manually changing the DOM
- Starting timers
useEffect(() => {
// Side effect code here
}, [dependencies]); // Dependency array
The Dependency Array#
The dependency array tells React when to run your effect:
- No array → Runs after every render
- Empty array
[]→ Runs once after mount - With dependencies
[dep1, dep2]→ Runs when dependencies change
Problem 1: Infinite Loops#
The Problem#
Your component re-renders infinitely, crashing the browser.
// ❌ Bad: Infinite loop
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => setUser(data)); // This triggers re-render
}); // Missing dependency array!
return <div>{user?.name}</div>;
}
Why it loops: Without a dependency array, useEffect runs after every render. setUser triggers a re-render, which runs useEffect again, creating an infinite cycle.
Solution 1: Add Dependency Array#
// ✅ Good: Only runs when userId changes
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => setUser(data));
}, [userId]); // Only re-run when userId changes
return <div>{user?.name}</div>;
}
Solution 2: Fix Object/Array Dependencies#
// ❌ Bad: Object reference changes every render
function Component({ config }) {
useEffect(() => {
doSomething(config);
}, [config]); // config is a new object every render!
return <div>...</div>;
}
// ✅ Good: Depend on specific values
function Component({ config }) {
useEffect(() => {
doSomething(config);
}, [config.apiUrl, config.timeout]); // Depend on primitives
return <div>...</div>;
}
// ✅ Better: Memoize the config
function Component({ config }) {
const memoizedConfig = useMemo(() => config, [config.apiUrl, config.timeout]);
useEffect(() => {
doSomething(memoizedConfig);
}, [memoizedConfig]);
return <div>...</div>;
}
Solution 3: Use Functional Updates#
// ❌ Bad: Depends on state that changes
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
setCount(count + 1); // Uses stale count
}, 1000);
return () => clearInterval(timer);
}, [count]); // Re-creates timer every second!
return <div>{count}</div>;
}
// ✅ Good: Functional update doesn't need count in deps
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
setCount(prev => prev + 1); // Uses latest count
}, 1000);
return () => clearInterval(timer);
}, []); // Only runs once
return <div>{count}</div>;
}
Problem 2: Stale Closures#
The Problem#
Your effect uses old values, even though state has updated.
// ❌ Bad: Stale closure
function ChatRoom({ roomId }) {
const [messages, setMessages] = useState([]);
useEffect(() => {
const connection = createConnection(roomId);
connection.on('message', (msg) => {
setMessages([...messages, msg]); // Uses stale messages!
});
connection.connect();
}, [roomId]); // Missing messages dependency
return <div>{messages.map(m => <div key={m.id}>{m.text}</div>)}</div>;
}
Why it's stale: The effect closure captures the initial messages array. When new messages arrive, it still uses the old empty array.
Solution 1: Include All Dependencies#
// ✅ Good: Includes messages in dependencies
function ChatRoom({ roomId }) {
const [messages, setMessages] = useState([]);
useEffect(() => {
const connection = createConnection(roomId);
connection.on('message', (msg) => {
setMessages(prev => [...prev, msg]); // Functional update
});
connection.connect();
return () => connection.disconnect();
}, [roomId, messages]); // Now includes messages
return <div>{messages.map(m => <div key={m.id}>{m.text}</div>)}</div>;
}
Better: Use functional updates to avoid needing messages in deps:
// ✅ Better: Functional update, no messages dependency needed
function ChatRoom({ roomId }) {
const [messages, setMessages] = useState([]);
useEffect(() => {
const connection = createConnection(roomId);
connection.on('message', (msg) => {
setMessages(prev => [...prev, msg]); // Always uses latest
});
connection.connect();
return () => connection.disconnect();
}, [roomId]); // Only roomId needed
return <div>{messages.map(m => <div key={m.id}>{m.text}</div>)}</div>;
}
Solution 2: Use Refs for Values That Shouldn't Trigger Effects#
// ✅ Good: Use ref for value that shouldn't trigger re-runs
function SearchBox({ onSearch }) {
const [query, setQuery] = useState('');
const queryRef = useRef(query);
// Keep ref in sync
useEffect(() => {
queryRef.current = query;
}, [query]);
// Effect doesn't re-run when query changes
useEffect(() => {
const timer = setTimeout(() => {
onSearch(queryRef.current); // Uses latest query
}, 500);
return () => clearTimeout(timer);
}, [onSearch]); // Only re-run when onSearch changes
return <input value={query} onChange={e => setQuery(e.target.value)} />;
}
Problem 3: Cleanup Not Running#
The Problem#
Your cleanup function doesn't run, causing memory leaks or stale subscriptions.
// ❌ Bad: No cleanup
function Timer() {
const [seconds, setSeconds] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setSeconds(s => s + 1);
}, 1000);
// Missing cleanup!
}, []);
return <div>{seconds} seconds</div>;
}
Why it's a problem: When the component unmounts, the interval keeps running, causing memory leaks and potential errors.
Solution: Always Return Cleanup#
// ✅ Good: Cleanup function
function Timer() {
const [seconds, setSeconds] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setSeconds(s => s + 1);
}, 1000);
return () => clearInterval(interval); // Cleanup!
}, []);
return <div>{seconds} seconds</div>;
}
Real-World Example: API Subscriptions#
// ✅ Good: Cleanup for subscriptions
function UserStatus({ userId }) {
const [status, setStatus] = useState('offline');
useEffect(() => {
const subscription = subscribeToUserStatus(userId, (newStatus) => {
setStatus(newStatus);
});
return () => {
subscription.unsubscribe(); // Cleanup subscription
};
}, [userId]);
return <div>Status: {status}</div>;
}
Problem 4: Race Conditions in Async Operations#
The Problem#
Multiple API calls race each other, and an older response overwrites a newer one.
// ❌ Bad: Race condition
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => setUser(data));
}, [userId]);
return <div>{user?.name}</div>;
}
The race: If userId changes quickly (user navigates fast), the first request might finish after the second, showing wrong data.
Solution: Cancel Previous Requests#
// ✅ Good: Cancel previous requests
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
let cancelled = false; // Flag to track if effect is still valid
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => {
if (!cancelled) { // Only update if not cancelled
setUser(data);
}
});
return () => {
cancelled = true; // Mark as cancelled on cleanup
};
}, [userId]);
return <div>{user?.name}</div>;
}
Better Solution: Use AbortController#
// ✅ Better: AbortController for fetch
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
const controller = new AbortController();
fetch(`/api/users/${userId}`, { signal: controller.signal })
.then(res => res.json())
.then(data => setUser(data))
.catch(err => {
if (err.name !== 'AbortError') {
console.error(err);
}
});
return () => {
controller.abort(); // Cancel the request
};
}, [userId]);
return <div>{user?.name}</div>;
}
Problem 5: Effects Running Too Often#
The Problem#
Your effect runs on every render, even when it shouldn't.
// ❌ Bad: Runs too often
function ExpensiveComponent({ data }) {
useEffect(() => {
expensiveOperation(data); // Runs every render!
}); // Missing dependency array
return <div>...</div>;
}
Solution 1: Memoize Dependencies#
// ✅ Good: Memoize object dependencies
function ExpensiveComponent({ config }) {
const memoizedConfig = useMemo(() => config, [
config.apiUrl,
config.timeout,
config.retries
]);
useEffect(() => {
expensiveOperation(memoizedConfig);
}, [memoizedConfig]);
return <div>...</div>;
}
Solution 2: Use Refs for Callbacks#
// ✅ Good: Use ref for callback that shouldn't trigger effect
function Component({ onSave }) {
const onSaveRef = useRef(onSave);
useEffect(() => {
onSaveRef.current = onSave; // Keep ref updated
}, [onSave]);
useEffect(() => {
// This effect doesn't re-run when onSave changes
const handleKeyPress = (e) => {
if (e.key === 's' && e.ctrlKey) {
onSaveRef.current(); // Uses latest onSave
}
};
window.addEventListener('keydown', handleKeyPress);
return () => window.removeEventListener('keydown', handleKeyPress);
}, []); // Only runs once
return <div>...</div>;
}
Common Patterns#
Pattern 1: Fetching Data on Mount#
function UserList() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false;
async function fetchUsers() {
try {
setLoading(true);
const response = await fetch('/api/users');
const data = await response.json();
if (!cancelled) {
setUsers(data);
setError(null);
}
} catch (err) {
if (!cancelled) {
setError(err.message);
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
fetchUsers();
return () => {
cancelled = true;
};
}, []); // Only fetch on mount
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
return <div>{users.map(u => <div key={u.id}>{u.name}</div>)}</div>;
}
Pattern 2: Subscribing to Events#
function WindowSize() {
const [size, setSize] = useState({
width: window.innerWidth,
height: window.innerHeight,
});
useEffect(() => {
function handleResize() {
setSize({
width: window.innerWidth,
height: window.innerHeight,
});
}
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []); // Only set up listener once
return <div>Window: {size.width} x {size.height}</div>;
}
Pattern 3: Updating Document Title#
function Page({ title }) {
useEffect(() => {
const previousTitle = document.title;
document.title = title;
return () => {
document.title = previousTitle; // Restore previous title
};
}, [title]);
return <div>...</div>;
}
Pattern 4: Debounced Search#
function SearchBox({ onSearch }) {
const [query, setQuery] = useState('');
const queryRef = useRef(query);
useEffect(() => {
queryRef.current = query;
}, [query]);
useEffect(() => {
const timer = setTimeout(() => {
onSearch(queryRef.current);
}, 500);
return () => clearTimeout(timer);
}, [query, onSearch]);
return <input value={query} onChange={e => setQuery(e.target.value)} />;
}
Debugging Tips#
1. Use ESLint Plugin#
Install eslint-plugin-react-hooks to catch dependency issues:
{
"plugins": ["react-hooks"],
"rules": {
"react-hooks/exhaustive-deps": "warn"
}
}
2. Log Dependencies#
useEffect(() => {
console.log('Effect running with:', { userId, filter });
// Your effect code
}, [userId, filter]);
3. Use React DevTools#
React DevTools Profiler shows which effects run and why components re-render.
4. Check for Object Reference Changes#
const prevConfigRef = useRef();
useEffect(() => {
if (prevConfigRef.current !== config) {
console.log('Config changed:', config);
prevConfigRef.current = config;
}
}, [config]);
Best Practices Summary#
- Always include dependencies - If you use a value, include it in the dependency array
- Use functional updates -
setState(prev => ...)avoids stale closures - Always cleanup - Return cleanup functions for subscriptions, timers, listeners
- Handle race conditions - Cancel async operations in cleanup
- Memoize object dependencies - Use
useMemofor objects/arrays in dependencies - Use refs for callbacks - When callbacks shouldn't trigger effect re-runs
- Enable ESLint plugin - Catch dependency issues automatically
When NOT to Use useEffect#
Sometimes, useEffect isn't the right tool:
1. Transforming Data for Render#
// ❌ Bad: Using useEffect for derived state
function UserList({ users }) {
const [filteredUsers, setFilteredUsers] = useState([]);
useEffect(() => {
setFilteredUsers(users.filter(u => u.active));
}, [users]);
return <div>...</div>;
}
// ✅ Good: Compute during render
function UserList({ users }) {
const filteredUsers = users.filter(u => u.active);
return <div>...</div>;
}
2. Handling Events#
// ❌ Bad: Using useEffect for event handlers
function Button() {
useEffect(() => {
const handleClick = () => console.log('clicked');
button.addEventListener('click', handleClick);
return () => button.removeEventListener('click', handleClick);
}, []);
return <button>Click me</button>;
}
// ✅ Good: Use event handler prop
function Button({ onClick }) {
return <button onClick={onClick}>Click me</button>;
}
3. Resetting State on Prop Change#
// ❌ Bad: useEffect to reset state
function Form({ userId }) {
const [name, setName] = useState('');
useEffect(() => {
setName(''); // Reset when userId changes
}, [userId]);
return <input value={name} onChange={e => setName(e.target.value)} />;
}
// ✅ Good: Use key prop
function Form({ userId }) {
const [name, setName] = useState('');
return <input key={userId} value={name} onChange={e => setName(e.target.value)} />;
}
Key Takeaways#
- Dependency array is critical - Missing deps cause bugs, wrong deps cause infinite loops
- Always cleanup - Prevent memory leaks with cleanup functions
- Handle async properly - Cancel requests to avoid race conditions
- Use functional updates - Avoid stale closures with
setState(prev => ...) - Memoize object deps - Prevent unnecessary re-runs
- Enable ESLint plugin - Catch issues automatically
- Don't overuse useEffect - Some things are better computed during render
useEffect is powerful but requires understanding React's rendering cycle. Master these patterns, and you'll debug useEffect issues in minutes instead of hours.
FAQ#
Q: Should I always include all dependencies? A: Yes, unless you have a specific reason not to (like using a ref for a value that shouldn't trigger re-runs).
Q: Why does my effect run twice in development? A: React Strict Mode intentionally double-invokes effects in development to help find bugs. This won't happen in production.
Q: Can I use async functions directly in useEffect? A: No, but you can define an async function inside useEffect and call it.
Q: How do I prevent an effect from running on mount? A: Use a ref to track if it's the first render, or restructure your logic.
Q: What's the difference between useEffect and useLayoutEffect?
A: useLayoutEffect runs synchronously after DOM mutations but before paint. Use it when you need to measure DOM or prevent visual flicker.