Skip to content

Common useEffect Mistakes

The most common useEffect mistake: setting state inside an effect without specifying dependencies, causing an endless render→effect→render cycle.

// ❌ BAD — causes infinite loop
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
setCount(count + 1); // Triggers re-render → effect runs again → re-render...
}); // No dependency array — runs after EVERY render
return <p>{count}</p>;
}

Fix: Add a dependency array or remove the state update from the effect:

useEffect(() => {
setCount(prev => prev + 1);
}, []); // Runs only once

A stale closure occurs when the effect captures an old value of a variable and never updates it.

// ❌ BAD — count is always 0 inside the timeout
function DelayedLogger() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setTimeout(() => {
console.log(count); // Always logs 0 — captured at mount time
}, 3000);
return () => clearTimeout(id);
}, []); // count is NOT in deps
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}

Fix: Include count in the dependency array, or use a ref:

// Option 1: Add to deps
useEffect(() => {
const id = setTimeout(() => console.log(count), 3000);
return () => clearTimeout(id);
}, [count]); // Re-runs when count changes
// Option 2: Use a ref (no re-run needed)
const countRef = useRef(count);
countRef.current = count;
useEffect(() => {
const id = setTimeout(() => console.log(countRef.current), 3000);
return () => clearTimeout(id);
}, []);
function Profile({ userId }) {
const [user, setUser] = useState(null);
// ❌ BAD — userId is used inside but missing from deps
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(r => r.json())
.then(setUser);
}, []); // Never re-fetches when userId changes
}

Fix: Add userId to the dependency array.

// ❌ BAD — options is a new object every render
function Search({ options }) {
useEffect(() => {
fetchResults(options);
}, [options]); // Runs on EVERY render!
}

Fix: Use specific primitive values or memoize the object:

useEffect(() => {
fetchResults(options);
}, [options.query, options.page]); // Only runs when these specific values change
// ❌ BAD — event listener is never removed
useEffect(() => {
window.addEventListener('online', () => setIsOnline(true));
window.addEventListener('offline', () => setIsOnline(false));
}, []); // Listeners accumulate on every StrictMode mount/unmount

Fix: Always return a cleanup function:

useEffect(() => {
const goOnline = () => setIsOnline(true);
const goOffline = () => setIsOnline(false);
window.addEventListener('online', goOnline);
window.addEventListener('offline', goOffline);
return () => {
window.removeEventListener('online', goOnline);
window.removeEventListener('offline', goOffline);
};
}, []);
// ❌ BAD — unnecessary effect for derived state
const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);
// ✅ GOOD — compute during render
const fullName = `${firstName} ${lastName}`;
// ❌ BAD — useEffect doesn't support async callbacks directly
useEffect(async () => {
const data = await fetchData();
setData(data);
}, []); // Returns a Promise, not a cleanup function — React warns

Fix: Define the async function inside the effect:

useEffect(() => {
async function load() {
const data = await fetchData();
setData(data);
}
load();
}, []);
MistakeSymptomFix
No dependency arrayInfinite loopAdd [] or deps
Missing depsStale dataInclude all reactive values
Stale closureOld values in callbacksAdd deps or use refs
No cleanupMemory leaksAlways clean up
Object depsUnnecessary re-runsUse primitives or memoize
Async effectWarning, broken cleanupDefine async inside