React useEffect Common Mistakes and How to Fix Them (2026)
useEffect is the most misused hook in React's entire API — and in 2026, with concurrent rendering and Server Components in the mix, the blast radius of getting it wrong is larger than ever. These aren't hypothetical edge cases; they're production bugs that silently corrupt data, leak memory, and tank performance.
The Infinite Loop Trap — Dependency Array Mistakes That Break Performance

Understanding How Missing Dependencies Cause Infinite Loops
The mechanism is brutally simple: effect runs → updates state → triggers re-render → effect runs again. React re-executes effects after every render where a listed dependency changed. Miss a dependency, and you short-circuit that protection.
Here's the classic beginner trap:
// ❌ Wrong: This creates an infinite loop
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
setCount(count + 1); // reads `count`, but `count` isn't in deps
}, []); // runs once... but triggers a re-render each time
return <div>{count}</div>;
}
React runs the effect, setCount fires, component re-renders, and if you add count to the deps array, the effect fires again. You're stuck either way because the logic is fundamentally wrong — you're updating state that the effect itself reads.
// ✅ Correct: Use functional setState to avoid the dependency entirely
useEffect(() => {
setCount(prev => prev + 1);
}, []); // No dependency on `count` needed
// Or better: move the logic out of useEffect altogether
// if it's triggered by user interaction, use an event handler
The functional updater form prev => prev + 1 doesn't close over count at all — it receives the current value from React directly. Use it whenever you're deriving next state from current state inside an effect.
Debugging Infinite Loops with React DevTools Profiler
Don't just stare at the code. Open React DevTools, go to the Profiler tab, click Record, and watch the flame chart. If a component re-renders dozens of times in a second, you have a loop. Each render bar should be short and few — not a wall of identical frames.
The fastest low-tech method: drop a console.log('effect ran') inside the effect. If your browser console floods instantly, you've confirmed the loop. Add a counter ref to count invocations without triggering more renders:
const runCount = useRef(0);
useEffect(() => {
runCount.current += 1;
console.log('Effect ran', runCount.current, 'times');
// ...
}, [dependency]);
In production, tools like Sentry's performance tracing will show an unusually dense waterfall of identical XHR calls or state updates — a reliable signal that an effect loop slipped through code review.
Object and Function Recreation in Dependency Arrays
React uses shallow (reference) equality to compare dependencies between renders. An object literal like { id: 1 } creates a brand-new reference every render even though its contents haven't changed. This is one of the sneakiest infinite loop sources.
// ❌ Wrong: New object reference created every render
function UserProfile({ userId }) {
const options = { headers: { Authorization: `Bearer ${token}` } }; // new ref each render
useEffect(() => {
fetchUser(options); // effect re-runs every single render
}, [options]); // options reference always "changed"
}
// ✅ Correct: Memoize or extract primitive values
function UserProfile({ userId, token }) {
const options = useMemo(
() => ({ headers: { Authorization: `Bearer ${token}` } }),
[token] // only recreates when token actually changes
);
useEffect(() => {
fetchUser(options);
}, [options]);
// Even better: use primitives directly when possible
useEffect(() => {
fetchUser({ headers: { Authorization: `Bearer ${token}` } });
}, [token]); // primitive string — stable comparison
}
The same problem hits functions. An inline callback defined in render is a new function reference every time. Wrap it in useCallback or define it inside the effect itself where React's exhaustive-deps rule won't complain.
Stale Closures and Async Race Conditions — The Hidden Memory Leak
The Stale Closure Problem in Async Operations
When an async operation starts inside an effect, it captures the current values of state and props at that moment. If those values change before the async work completes, the callback still sees the old snapshot. React doesn't magically update closed-over variables.
// ❌ Wrong: stale `userId` captured in async callback
function UserPosts({ userId }) {
const [posts, setPosts] = useState([]);
useEffect(() => {
fetchPosts(userId).then(data => {
setPosts(data); // userId here might be stale if prop changed mid-fetch
});
}, []); // Missing userId in deps — classic mistake
}
// ✅ Correct: proper dependency + AbortController for cleanup
function UserPosts({ userId }) {
const [posts, setPosts] = useState([]);
useEffect(() => {
const controller = new AbortController();
fetchPosts(userId, { signal: controller.signal })
.then(data => {
if (!controller.signal.aborted) {
setPosts(data);
}
})
.catch(err => {
if (err.name !== 'AbortError') console.error(err);
});
return () => controller.abort(); // cancels if userId changes or component unmounts
}, [userId]); // userId in deps — re-runs when prop changes
}
Race Conditions in Parallel API Calls
User types "re" → request A fires. User types "rea" → request B fires. Request B resolves first, then request A resolves and overwrites the correct data. Your UI now shows results for "re" even though the search bar says "rea".
The AbortController pattern above handles this. When the dependency changes (each keystroke), cleanup runs and aborts the previous in-flight request before the new one starts. Here's a production-grade search implementation:
// ✅ Search with race condition prevention
function SearchResults() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [error, setError] = useState(null);
useEffect(() => {
if (!query.trim()) {
setResults([]);
return;
}
const controller = new AbortController();
const { signal } = controller;
async function search() {
try {
const response = await fetch(`/api/search?q=${query}`, { signal });
const data = await response.json();
setResults(data);
setError(null);
} catch (err) {
if (err.name !== 'AbortError') {
setError('Search failed');
}
// AbortError means a newer request replaced this one — that's fine
}
}
search();
return () => controller.abort();
}, [query]);
return (/* render results */);
}
The AbortError check is non-negotiable. Without it, every aborted request logs a false error to your monitoring dashboard.
Custom Hooks and Effect Abstraction Pitfalls
Custom hooks are great for reuse, but they hide effects from the component that uses them. A stale closure inside useFetch becomes nearly invisible to the developer consuming it.
// ❌ Wrong: useFetch with missing dependency
function useFetch(url) {
const [data, setData] = useState(null);
useEffect(() => {
fetch(url).then(r => r.json()).then(setData);
}, []); // url never updates the effect — stale forever
return data;
}
// ✅ Correct: dependency-transparent custom hook
function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const controller = new AbortController();
setLoading(true);
fetch(url, { signal: controller.signal })
.then(r => r.json())
.then(data => { setData(data); setLoading(false); })
.catch(err => { if (err.name !== 'AbortError') setLoading(false); });
return () => controller.abort();
}, [url]); // passes url through — caller controls re-fetch behavior
return { data, loading };
}
See more about structuring reusable hooks in our post on React Custom Hook Patterns.
Cleanup Functions and Memory Leak Prevention — Beyond Unsubscribe
When Cleanup Functions Don't Execute and Silent Memory Leaks Result
Cleanup runs in two situations: before the next effect fires (when deps change) and when the component unmounts. That's it. If you attach a window event listener and forget the cleanup, every remount adds another listener — they stack up.
// ❌ Wrong: listener leaks on every render where `handler` changes
useEffect(() => {
window.addEventListener('resize', handleResize);
// No return — listener never removed
}, [handleResize]);
// ✅ Correct: always return cleanup for external subscriptions
useEffect(() => {
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, [handleResize]);
Timer leaks are equally common:
// ❌ Wrong: interval runs forever after unmount
useEffect(() => {
const id = setInterval(() => setTick(t => t + 1), 1000);
// missing cleanup
}, []);
// ✅ Correct
useEffect(() => {
const id = setInterval(() => setTick(t => t + 1), 1000);
return () => clearInterval(id);
}, []);
To detect leaks, open Chrome DevTools → Memory tab → take a Heap Snapshot before and after mounting/unmounting a component repeatedly. If retained objects grow, you have a leak.
Closure Traps in Cleanup Functions
Cleanup functions close over the same snapshot as the effect body. If your cleanup needs a current value that may have changed, a ref is more reliable than depending on closure state.
// ❌ Wrong: cleanup closes over stale userId
useEffect(() => {
const sub = subscribeToUser(userId, handleUpdate);
return () => {
unsubscribeFromUser(userId, handleUpdate); // may be old userId
};
}, [userId]);
// ✅ Correct: use the value captured at effect time — that IS the user you subscribed for
// The above is actually fine IF you always clean up the same userId you subscribed to.
// The real problem is when you try to access *current* state in cleanup:
function useWebSocket(userId) {
const userIdRef = useRef(userId);
useEffect(() => { userIdRef.current = userId; }, [userId]);
useEffect(() => {
const ws = new WebSocket(`/ws/${userId}`);
return () => {
// Use the ref only if you need the CURRENT userId at cleanup time
// For unsubscribing the same connection you opened, capture it in closure:
ws.close();
};
}, [userId]);
}
Edge Cases: Overlapping Effects and Cleanup Order
Under React's Concurrent Mode and Strict Mode (development), effects intentionally fire twice — React mounts, unmounts, and remounts to verify cleanup works correctly. If your app breaks in Strict Mode, your cleanup is incomplete.
// This pattern breaks in Strict Mode — reveals a real bug
useEffect(() => {
someGlobalSingleton.init(); // called twice in dev
return () => someGlobalSingleton.destroy(); // destroyed after first mount
}, []);
// ✅ Make initialization idempotent
useEffect(() => {
if (!someGlobalSingleton.initialized) {
someGlobalSingleton.init();
}
return () => someGlobalSingleton.destroy();
}, []);
Strict Mode's double-invocation behavior is documented in the official React docs on useEffect — read that section before assuming Strict Mode is wrong.
Performance Optimization and 2026 Best Practices

Splitting Effects by Concern
One effect, one responsibility. Developers who merge unrelated side effects into a single useEffect create dependencies that fight each other and make debugging miserable.
// ❌ Wrong: two concerns in one effect
useEffect(() => {
document.title = `${user.name} — Dashboard`;
analytics.trackPageView(route);
}, [user.name, route]); // one change fires both side effects
// ✅ Correct: split into independent effects
useEffect(() => {
document.title = `${user.name} — Dashboard`;
}, [user.name]);
useEffect(() => {
analytics.trackPageView(route);
}, [route]);
useEffect vs useLayoutEffect — Picking the Right Tool
Most effects belong in useEffect. Use useLayoutEffect only when you need to read or mutate the DOM synchronously before the browser paints — things like measuring element dimensions or preventing layout flicker on initial render.
| Scenario | Correct Hook | Why |
|---|---|---|
| Fetch data from API | useEffect |
Async, no DOM measurement needed |
| Subscribe to events | useEffect |
Doesn't block paint |
| Read DOM element size | useLayoutEffect |
Must run before paint to avoid flicker |
| Animate element on mount | useLayoutEffect |
Synchronous DOM setup before browser sees it |
| Update document title | useEffect |
No visual impact from timing |
| Set up WebSocket | useEffect |
Async setup, doesn't block render |
useLayoutEffect runs synchronously and blocks painting — misusing it on slow operations will visibly freeze your UI. Default to useEffect and only reach for useLayoutEffect when you have a concrete visual artifact to fix.
The eslint-plugin-react-hooks Enforcer — Use It, Always
The eslint-plugin-react-hooks exhaustive-deps rule catches missing dependencies automatically. In 2026 there's no excuse for not running it. Configure it as an error, not a warning:
// .eslintrc.json
{
"plugins": ["react-hooks"],
"rules": {
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "error"
}
}
When the linter tells you to add a dependency and that would cause a re-render problem, the fix is almost never to suppress the warning. It's to restructure the code — use a ref, move logic outside the component, or split the effect. See our broader breakdown in React Hooks Performance Patterns.
Frequently Asked Questions
Q: Why does my useEffect run twice in development but not production?
A: React Strict Mode intentionally mounts, unmounts, and remounts components in development to surface cleanup bugs. This behavior doesn't happen in production builds. If double-firing breaks your app, you have a missing or incorrect cleanup function — fix the cleanup, don't disable Strict Mode.
Q: Should I use useEffect for derived state?
A: No. Computing a value from existing state or props doesn't need an effect at all — calculate it directly during render or memoize it with useMemo. Using useEffect to sync state with other state adds an extra render cycle and makes data flow harder to trace.
Q: When should I use an empty dependency array vs. no array at all?
A: An empty array [] runs the effect once after initial mount — use it for one-time setup like connecting to a service. No array runs the effect after every render — this is rarely intentional and almost always a bug or a sign the effect belongs in an event handler instead.
Wrap-up
The four failure modes — dependency array mistakes, stale closures, missing cleanup, and split-concern violations — cover the overwhelming majority of useEffect bugs you'll encounter in real codebases. The mental shift is from "lifecycle method" to "synchronization with an external system": if you're not syncing with something outside React, you probably don't need an effect at all.
Start today by enabling eslint-plugin-react-hooks as an error in your project — it will surface every issue covered in this post before it ships.
References
- 15 common useEffect mistakes to avoid in your React apps - LogRocket Blog
- here are the 4 biggest useEffect mistakes I found : r/reactjs - Reddit
- Myths about useEffect | Epic React by Kent C. Dodds
- React’s `useEffect`: Best Practices, Pitfalls, and Modern JavaScript Insights - DEV Community
- How to avoid making the same useEffect mistakes as me
- 15 common useEffect mistakes to avoid in your React apps
Comments
Post a Comment