useEffect Has Caused Me More Bugs Than Every Other Hook Combined
useEffect Has Caused Me More Bugs Than Every Other Hook Combined#
useEffect has caused me more bugs than every other hook combined. And I am including the bugs I wrote myself.
I am not blaming the API. The API is well-designed. The problem is that useEffect requires a mental model that most tutorials — including the ones I learned from — never fully explain. They show you the syntax, they give you a counter example, and then they send you off to build production apps with async data fetching and WebSocket connections. And you write bugs. Lots of them.
After reviewing hundreds of pull requests containing React code, the single most reliable source of production bugs is useEffect. This post is the deep dive I wish had existed when I was learning. Every pattern here comes from a real bug I either wrote, reviewed, or helped debug in production.
This is going to be a long one.
The Dependency Array Is Not Optional Configuration#
The most important thing to understand about useEffect is what the dependency array actually means.
It is NOT: "run this effect when these values change" (though that is the observable behavior).
It IS: "these are all the values from the component scope that this effect uses."
The distinction matters because if you omit a value the effect uses, you get a stale closure bug. The effect runs with whatever values were present when it was last created, even if those values have since changed. You will not get an error. Your tests will probably pass. And then in production, a user will change a dropdown, the wrong data will load, and nobody will understand why.
function DocumentTitle({ title }: { title: string }) {
useEffect(() => {
document.title = title;
return () => {
document.title = 'My App';
};
}, [title]); // title is used, so title must be listed
return null;
}
The ESLint rule react-hooks/exhaustive-deps enforces this. When it flags a missing dependency, do not silence it. Fix the logic. I have never once — not a single time in over a year of writing hooks — encountered a case where the right answer was to ignore that lint warning. If the rule says you are missing a dependency, you are missing a dependency.
Data Fetching: The Race Condition Nobody Warns You About#
Here is a data fetching pattern that looks correct and is not:
// This has a race condition. Can you see it?
function UserPosts({ userId }: { userId: string }) {
const [posts, setPosts] = useState<Post[]>([]);
useEffect(() => {
fetch(`/api/users/${userId}/posts`)
.then(res => res.json())
.then(data => setPosts(data));
}, [userId]);
return <PostList posts={posts} />;
}
The problem: if userId changes from "alice" to "bob" before Alice's fetch completes, you may end up showing Bob's profile with Alice's posts. The response that arrives last wins, regardless of which request was most recent. This exact bug surfaces in customer detail views when a user clicks through multiple records quickly — Customer A's orders appearing alongside Customer C's profile info is a very bad day in production.
The fix is the cancellation pattern:
function UserPosts({ userId }: { userId: string }) {
const [posts, setPosts] = useState<Post[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setIsLoading(true);
setError(null);
fetch(`/api/users/${userId}/posts`)
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then((data: Post[]) => {
if (!cancelled) {
setPosts(data);
setIsLoading(false);
}
})
.catch((err: Error) => {
if (!cancelled) {
setError(err.message);
setIsLoading(false);
}
});
return () => {
cancelled = true;
};
}, [userId]);
if (isLoading) return <PostListSkeleton />;
if (error) return <ErrorMessage message={error} />;
return <PostList posts={posts} />;
}
The cleanup function sets cancelled = true. When userId changes, React runs the cleanup for the old effect before running the new one. The old fetch's callbacks check cancelled and silently discard their results if the effect is no longer current.
For AbortController-based cancellation, which is even cleaner because it cancels the actual network request:
useEffect(() => {
const controller = new AbortController();
setIsLoading(true);
fetch(`/api/reports/${reportId}`, { signal: controller.signal })
.then(res => res.json())
.then((data: Report) => {
setReport(data);
setIsLoading(false);
})
.catch((err: Error) => {
if (err.name !== 'AbortError') {
setError(err.message);
setIsLoading(false);
}
// AbortError means we cancelled it ourselves — ignore
});
return () => controller.abort();
}, [reportId]);
EDIT (2020-09-22): A reader on Reddit pointed out that AbortController is not supported in IE11. If you need IE11 support (and in 2020, many enterprise apps still do), the cancelled boolean pattern is the safer choice. AbortController works in all modern browsers though.
WebSocket Subscriptions#
Effects that establish connections need cleanup that tears them down. This seems obvious in writing but it is very easy to forget in practice:
interface PriceUpdate {
symbol: string;
price: number;
change: number;
}
function LivePriceTicker({ symbol }: { symbol: string }) {
const [priceData, setPriceData] = useState<PriceUpdate | null>(null);
const [connectionStatus, setConnectionStatus] = useState<'connecting' | 'connected' | 'disconnected'>('connecting');
useEffect(() => {
const ws = new WebSocket(`wss://prices.example.com/stream/${symbol}`);
ws.onopen = () => setConnectionStatus('connected');
ws.onclose = () => setConnectionStatus('disconnected');
ws.onmessage = (event) => {
const update: PriceUpdate = JSON.parse(event.data);
setPriceData(update);
};
ws.onerror = () => {
setConnectionStatus('disconnected');
};
return () => {
ws.close(); // Don't forget this. I have forgotten this.
};
}, [symbol]);
if (!priceData) return <span>Loading {symbol}...</span>;
return (
<div className={`ticker ticker--${connectionStatus}`}>
<span className="ticker__symbol">{symbol}</span>
<span className="ticker__price">${priceData.price.toFixed(2)}</span>
<span className={`ticker__change ${priceData.change >= 0 ? 'positive' : 'negative'}`}>
{priceData.change >= 0 ? '+' : ''}{priceData.price.toFixed(2)}%
</span>
</div>
);
}
When symbol changes, the cleanup closes the old WebSocket before the effect runs again with the new symbol. Without that cleanup, you would be leaking connections every time the user switches symbols.
Event Listeners#
function useKeyboardShortcut(key: string, handler: () => void, active: boolean = true) {
useEffect(() => {
if (!active) return;
function handleKeyDown(event: KeyboardEvent) {
if (event.key === key && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
handler();
}
}
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [key, handler, active]);
// Note: if 'handler' is defined inline in the component, it changes every render
// and this effect re-subscribes every render. Wrap handler in useCallback.
// I know, I know. We'll get to useCallback eventually.
}
// Usage
function DocumentEditor({ document, onSave, onClose }: EditorProps) {
useKeyboardShortcut('s', onSave);
useKeyboardShortcut('w', onClose);
return (
<div className="editor">
{/* ...editor UI */}
</div>
);
}
Timers and Intervals#
function CountdownTimer({ durationSeconds, onExpired }: {
durationSeconds: number;
onExpired: () => void;
}) {
const [remaining, setRemaining] = useState(durationSeconds);
useEffect(() => {
if (remaining <= 0) {
onExpired();
return;
}
const timer = setTimeout(() => {
setRemaining(prev => prev - 1);
}, 1000);
return () => clearTimeout(timer);
}, [remaining, onExpired]);
// Each tick: old timeout cleared, new one set.
// Safer than setInterval, which can drift.
const minutes = Math.floor(remaining / 60);
const seconds = remaining % 60;
return (
<div className={`countdown ${remaining <= 10 ? 'countdown--urgent' : ''}`}>
{String(minutes).padStart(2, '0')}:{String(seconds).padStart(2, '0')}
</div>
);
}
Chained setTimeouts are preferable to setInterval for countdowns. setInterval can drift if the callback takes longer than the interval, and it is harder to clean up correctly. The setTimeout-chain gives you more control over the timing.
Effects That Run Once#
An empty dependency array [] means the effect synchronizes with nothing — it runs once after the first render and the cleanup runs when the component unmounts:
function AnalyticsProvider({ children }: { children: React.ReactNode }) {
useEffect(() => {
analytics.initialize({ userId: null });
return () => {
analytics.flush();
};
}, []);
return <>{children}</>;
}
But here is my strong opinion on this: if your empty array is hiding a real dependency — if your effect uses a prop or state value but you left it out to avoid re-running — you have a stale closure bug. The empty array is only correct when the effect truly uses no values from the component scope. I see this pattern abused constantly. People reach for [] like it means "just run once please" and it does not. It means "this effect has no dependencies." Those are different statements.
Separating Concerns Across Multiple Effects#
One component can have multiple useEffect calls. Split them by concern:
function ProjectDashboard({ projectId, userId }: Props) {
const [project, setProject] = useState<Project | null>(null);
const [activity, setActivity] = useState<Activity[]>([]);
// Effect 1: sync project data with projectId
useEffect(() => {
let cancelled = false;
api.getProject(projectId).then(data => {
if (!cancelled) setProject(data);
});
return () => { cancelled = true; };
}, [projectId]);
// Effect 2: sync activity feed (depends on both projectId and userId)
useEffect(() => {
const unsubscribe = activityStream.subscribe(projectId, userId, (event) => {
setActivity(prev => [event, ...prev].slice(0, 50)); // keep last 50
});
return unsubscribe;
}, [projectId, userId]);
// Effect 3: track page view
useEffect(() => {
analytics.trackPageView('project_dashboard', { projectId });
}, [projectId]);
// ...render
}
Three separate effects, each with a clear single responsibility. This is easier to read, easier to debug, and each effect's dependencies are honest about what it actually uses. Stuffing everything into one giant useEffect because "it all runs on mount" is a common anti-pattern. Split effects by concern. Your future self will thank you.
The Three Bug Categories#
In my experience, useEffect bugs fall into three categories and they all stem from the same root cause: not trusting the dependency array.
-
Missing a dependency feels safe ("I only want this to run once") until the component renders in a state where the stale value causes incorrect behavior.
-
Forgetting cleanup for subscriptions and timers. This causes memory leaks that are invisible until your app has been running for a while and starts getting slow.
-
Missing cancellation for async operations. The race condition problem I described above.
The React team's design is correct: if your effect uses a value, that value should be in the dependency array. If you cannot add a value without causing an infinite loop, that is telling you something about the structure of your effect — not about a deficiency in React.
When I get a useEffect-related bug report, I check in this order: are the dependencies honest? Is there cleanup? Is there a cancellation guard for async work?
Nine out of ten times, one of those three is the answer.
Pitfalls (The Short Version)#
Omitting dependencies to "optimize" re-runs. Missing dependencies do not optimize anything. They produce stale closures that are harder to debug than a slightly too-frequent effect.
Forgetting cleanup for intervals. A setInterval without a clearInterval in cleanup will keep firing after the component unmounts. Memory leaks. React warnings in the console. Confusion.
Object and function references in deps. If an object or function is recreated on every render, including it in deps triggers the effect on every render. Use useMemo and useCallback to stabilize references, or restructure to avoid the dependency.
Running effects on every render by accident. Forgetting the dependency array entirely means the effect runs after every single render. This is rarely what you want and it is a surprisingly common mistake.
Next: useContext, which solves prop drilling. And the performance traps it introduces that nobody talks about.