useEffect is the most powerful and most misunderstood React hook. Whether your effect is not running at all, running on every render, causing an infinite loop, or crashing after component unmount — this guide covers every scenario with broken code and exact fixes.
Mental model: useEffect runs after every render by default. The dependency array restricts when it re-runs. The returned function is cleanup — it runs before the next effect and on unmount.
Issue 1: useEffect Runs Twice in React 18 (Strict Mode)
React 18 Strict Mode intentionally mounts, unmounts, and remounts components in development. This causes every useEffect to fire twice on mount — once for the initial mount and once for the test remount. This only happens in development builds; production is unaffected. The goal is to surface effects that are not properly cleaned up.
Symptom — API called twice on mount:
// In React 18 StrictMode this runs twice in development
useEffect(() => {
fetch('/api/user').then(r => r.json()).then(setUser);
// No cleanup → React cannot cancel the first request
}, []);Fixed — add AbortController cleanup:
useEffect(() => {
const controller = new AbortController();
fetch('/api/user', { signal: controller.signal })
.then(r => r.json())
.then(setUser)
.catch(err => {
if (err.name !== 'AbortError') throw err;
});
return () => controller.abort(); // ✅ cancels first request on remount
}, []);Issue 2: Infinite Loop — Object or Function in Dependency Array
Objects and functions are compared by reference in JavaScript. If you create an object or function inside the component body and include it in the dependency array, a new reference is created on every render, causing useEffect to re-run, which triggers another render, which creates another new reference — an infinite loop.
Bug — new object reference on every render:
// ❌ options is a new object on every render
const options = { method: 'GET', cache: 'no-store' };
useEffect(() => {
fetchData(options); // triggers re-render → new options → loops
}, [options]); // ← infinite loop!
// ❌ Same problem with functions
const fetchUser = () => fetch('/api/user');
useEffect(() => {
fetchUser();
}, [fetchUser]); // ← infinite loop!Fixed — useMemo / useCallback / move outside component:
// ✅ Option 1: define outside component (stable reference)
const OPTIONS = { method: 'GET', cache: 'no-store' };
// ✅ Option 2: useMemo for objects
const options = useMemo(() => ({ method: 'GET' }), []);
// ✅ Option 3: useCallback for functions
const fetchUser = useCallback(() => fetch('/api/user'), []);
useEffect(() => {
fetchUser();
}, [fetchUser]); // stable reference → runs onceIssue 3: Missing Dependency Warning (ESLint exhaustive-deps)
The react-hooks/exhaustive-deps ESLint rule warns when you read a value inside useEffect but do not include it in the dependency array. Ignoring these warnings leads to stale closure bugs. Most of the time the correct fix is to add the dependency.
Warning — userId not in deps:
// ❌ ESLint: React Hook useEffect has a missing dependency: 'userId'
useEffect(() => {
fetchUser(userId); // reads userId but userId not in deps
}, []); // ← will use stale userId if it ever changesFixed — add userId to dependency array:
useEffect(() => {
fetchUser(userId); // ✅ re-fetches when userId changes
}, [userId]);
// When to safely suppress the warning:
// Only when you intentionally want a value frozen at mount
// and you understand the stale-closure implications
// eslint-disable-next-line react-hooks/exhaustive-depsIssue 4: Async Inside useEffect — Wrong Pattern
You cannot make the useEffect callback itself async. An async function returns a Promise, but React expects the callback to return either undefined or a cleanup function. Returning a Promise causes React to ignore the cleanup and may produce unexpected behavior.
Wrong — async callback causes React warning:
// ❌ useEffect callback must not be async
useEffect(async () => {
const data = await fetch('/api/data').then(r => r.json());
setData(data);
// React ignores the cleanup from an async callback
}, []);Fixed — define and call async function inside:
useEffect(() => {
const controller = new AbortController();
async function fetchData() {
try {
const res = await fetch('/api/data', { signal: controller.signal });
const json = await res.json();
setData(json);
} catch (err) {
if (err.name !== 'AbortError') setError(err.message);
}
}
fetchData(); // ✅ call the async function
return () => controller.abort(); // ✅ cleanup still works
}, []);Issue 5: useEffect Not Running — Empty Deps vs No Array
The second argument to useEffect controls when it runs. Many developers confuse an empty array with no array at all.
| Pattern | When it runs |
|---|---|
| useEffect(() => …) | After every render |
| useEffect(() => …, []) | Once after initial mount only |
| useEffect(() => …, [a, b]) | After mount and whenever a or b changes |
// Runs after EVERY render (no array)
useEffect(() => {
document.title = `Count: ${count}`;
});
// Runs ONCE after mount (empty array)
useEffect(() => {
initializeThirdPartyLib();
}, []);
// Runs when count or userId changes
useEffect(() => {
analytics.track('page-view', { count, userId });
}, [count, userId]);Issue 6: Cleanup Function — Subscriptions, Timers, Fetch
Without cleanup, long-running side effects continue after the component unmounts, causing memory leaks and errors when they try to update state on a dead component. Always return a cleanup function for subscriptions, intervals, and fetch requests.
Wrong — timer keeps running after unmount:
useEffect(() => {
const timer = setInterval(() => {
setTick(t => t + 1); // ❌ runs after component unmounts
}, 1000);
// No cleanup → memory leak
}, []);Fixed — return cleanup for all side effects:
// ✅ Timer cleanup
useEffect(() => {
const timer = setInterval(() => setTick(t => t + 1), 1000);
return () => clearInterval(timer);
}, []);
// ✅ Event listener cleanup
useEffect(() => {
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [handleResize]);
// ✅ WebSocket cleanup
useEffect(() => {
const ws = new WebSocket('wss://api.example.com');
ws.onmessage = e => setMessages(prev => [...prev, e.data]);
return () => ws.close();
}, []);Issue 7: State Update After Unmount
The classic React warning: "Can't perform a React state update on an unmounted component." This happens when an async operation completes and calls setState after the component has already unmounted. Use an isMounted flag or AbortController to guard against this.
Bug — setState called after unmount:
useEffect(() => {
fetch('/api/slow-endpoint')
.then(r => r.json())
.then(data => {
setData(data); // ❌ component may have unmounted by now
});
// No cleanup → React warning in console
}, []);Fixed — AbortController (preferred) or isMounted flag:
// ✅ Method 1: AbortController (recommended)
useEffect(() => {
const controller = new AbortController();
fetch('/api/slow-endpoint', { signal: controller.signal })
.then(r => r.json())
.then(setData)
.catch(err => { if (err.name !== 'AbortError') setError(err.message); });
return () => controller.abort();
}, []);
// ✅ Method 2: isMounted flag (for non-fetch async)
useEffect(() => {
let isMounted = true;
someAsyncOperation().then(data => {
if (isMounted) setData(data);
});
return () => { isMounted = false; };
}, []);Debug API responses in our CORS Tester
If your useEffect fetch is failing silently, it may be a CORS issue. Test your API endpoint to see exactly what headers are returned and why the request is blocked.
Open CORS Tester →Frequently Asked Questions
Why does useEffect run twice in React 18?
React 18 Strict Mode double-invokes effects in development to help surface missing cleanup functions. Production is unaffected. Add a cleanup function (e.g. AbortController) to make your effect idempotent.
How do I run useEffect only on mount?
Pass an empty dependency array as the second argument: useEffect(() => { ... }, []). The effect runs exactly once after the first render.
How do I fix an infinite loop in useEffect?
Identify which dependency triggers a state update that changes that same dependency. Wrap object/function dependencies with useMemo/useCallback, or move them outside the component.
How do I use async/await inside useEffect?
Define an async function inside the effect callback and call it immediately. Never make the callback itself async — React expects a cleanup function, not a Promise.
What is useEffect cleanup and when do I need it?
Cleanup is the function returned from useEffect. React calls it before re-running the effect and on unmount. You need it for subscriptions, timers, and fetch requests to prevent memory leaks.