Chapter 35
Hard Bugs and How to Find Them
The five hardest React SPA bug classes — stale closures, hydration mismatches, memory leaks, render-loop traps, and race conditions — with debugging recipes.
Published 2026-05-23
🎯 Chapter Goal — After this chapter you can recognise and fix the five hardest classes of React SPA bugs — stale closures, hydration mismatches, memory leaks, render-loop traps, race conditions — by their symptoms, with a debugging recipe per class.
🧭 Prerequisites — Ch 2 (hooks, hydration), Ch 13 (state), Ch 34 (debugging tools).
🔹 35.1 Stale closures in hooks
The bug: a hook callback captures values that have since changed, and uses the stale ones.
const Counter = () => {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => setCount(count + 1), 1000); // ← stale
return () => clearInterval(id);
}, []); // ← empty deps
return <div>{count}</div>;
};
The interval captures count at value 0 and never updates. After 5 seconds, count is 1, not 5.
Three fixes:
// 1. Use the functional setter
setCount((c) => c + 1);
// 2. Add `count` to the dependency array (causes interval re-creation each tick — usually wrong)
useEffect(() => { /* … */ }, [count]);
// 3. Use a ref to read the latest value
const countRef = useRef(0);
useEffect(() => { countRef.current = count; });
useEffect(() => {
const id = setInterval(() => setCount(countRef.current + 1), 1000);
return () => clearInterval(id);
}, []);
For setters, always prefer #1 (functional update). For non-setter callbacks that need the latest value, #3 (the ref pattern) is idiomatic.
Detection → the ESLint rule react-hooks/exhaustive-deps catches most. The compiler (Ch 2 §2.1) doesn’t fix stale closures — it preserves your code’s semantics, including the bugs.
🔹 35.2 Hydration mismatches
Covered in detail in Ch 2 §2.8. Summary for the bug-pattern context:
Symptoms: console error “Hydration failed because the initial UI does not match what was rendered on the server.” The 19.2 overlay shows the offending element and the server-vs-client diff.
Canonical causes:
Date.now()/new Date()in render — server time differs from client time.Math.random()in render.localStorage/sessionStoragereads at render time — undefined on server.- Locale-aware date formatting where server/client locales differ.
Fix pattern: defer the dynamic value to useEffect:
const [now, setNow] = useState<string>('');
useEffect(() => { setNow(new Date().toISOString()); }, []);
return <time>{now || 'loading…'}</time>;
The escape hatch — suppressHydrationWarning — for the exact element that legitimately mismatches:
<time suppressHydrationWarning>{new Date().toLocaleString()}</time>
Don’t wrap a whole subtree; you’ll silence real bugs.
🔹 35.3 Memory leaks (event listeners, subscriptions)
The leak class: a long-lived effect attaches a listener / subscription to a long-lived target (window, document, a WebSocket, a global event bus), but the cleanup is wrong or missing.
// ❌ leak
useEffect(() => {
window.addEventListener('resize', onResize);
// missing: return () => window.removeEventListener('resize', onResize);
}, []);
Every mount adds a listener. Every unmount leaves it. After 100 mounts: 100 listeners; onResize runs 100 times per resize event.
Detection in the wild:
- Chrome DevTools → Performance Monitor. Watch “JS event listeners” count over time. Should be roughly stable; growing monotonically → leak.
- Memory profiler. Take a heap snapshot. Navigate around the app. Take another. Diff. Detached DOM nodes / closures held by listeners stand out.
- React DevTools. If you have one suspect component, mount and unmount it repeatedly. Listener count should return to baseline.
The pattern that’s reliably correct:
useEffect(() => {
const onResize = () => { /* … */ };
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
For WebSockets / EventSource / RxJS subscriptions, mirror the same shape: subscribe in effect, unsubscribe in cleanup.
🔹 35.4 Render-loop traps with global state
The trap: a component reads from a store, computes a derived value, and writes it back to the store. Each write triggers a re-render that triggers another write.
// ❌ render loop
const InvoiceTotal = () => {
const items = useCart((s) => s.items);
const setTotal = useCart((s) => s.setTotal);
const total = items.reduce((s, i) => s + i.price, 0);
setTotal(total); // ← writes to store on every render → re-renders → ...
return <div>${total}</div>;
};
The fix: make the derived value a selector, not a stored value:
// ✅ derived in the store
const useCart = create((set, get) => ({
items: [],
total: () => get().items.reduce((s, i) => s + i.price, 0),
// ...
}));
// In the component:
const total = useCart((s) => s.total());
return <div>${total}</div>;
Or compute inline without writing back.
Detection: the React error “Maximum update depth exceeded” is the loud version. The quiet version: CPU spikes to 100 %, fan spins up, nothing visibly broken. React DevTools Profiler shows hundreds of commits per second.
🔹 35.5 Reproducing race conditions deterministically
The race class: two async operations finish in unpredictable order; the wrong one’s result wins.
// ❌ race
const Profile = ({ userId }: { userId: string }) => {
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
fetch(`/api/users/${userId}`).then((r) => r.json()).then(setUser);
}, [userId]);
return user ? <Card user={user} /> : null;
};
If userId changes from A to B quickly, both fetches are in flight. A resolves second; setUser(A) runs after setUser(B). The UI shows user A’s data even though the URL is user B.
Fix patterns:
AbortController
useEffect(() => {
const ac = new AbortController();
fetch(`/api/users/${userId}`, { signal: ac.signal })
.then((r) => r.json())
.then(setUser)
.catch((e) => { if (e.name !== 'AbortError') throw e; });
return () => ac.abort();
}, [userId]);
TanStack Query (Ch 14)
Cancels the previous request automatically when queryKey changes:
const { data: user } = useQuery({
queryKey: ['user', userId],
queryFn: ({ signal }) => fetch(`/api/users/${userId}`, { signal }).then((r) => r.json()),
});
Sequence-token pattern
const seq = useRef(0);
useEffect(() => {
const my = ++seq.current;
fetch(/* … */).then((data) => {
if (my === seq.current) setUser(data); // ignore stale responses
});
}, [userId]);
Reproducing locally — Chrome DevTools → Network → throttle to “Slow 3G” → click between users rapidly. The race manifests deterministically.
🪤 Common Pitfalls
- Adding
countto auseEffect’s deps to fix a stale closure → the effect re-runs constantly. suppressHydrationWarningon an ancestor instead of the exact element.- Adding event listeners without cleanup, then debugging “phantom keyboard handlers.”
- Writing to a store from inside
render()→ render loop. - Network race “fixed” with a setTimeout — non-deterministic; still wrong.
- Reaching for a memory profiler before you’ve turned on React DevTools’ highlight-renders.
✅ Recap
- Stale closures: functional setters or the ref pattern.
- Hydration: defer client-only values to
useEffect. - Memory leaks: every subscription needs a cleanup; verify with the Performance Monitor.
- Render loops: derive in selectors, never write back during render.
- Races: AbortController, TanStack Query, or sequence-token.
🔗 Further Reading
- React docs — “Synchronizing with effects.”
- Ch 14 (TanStack Query — handles cancellation for free).
- Ch 34 (debugging tools).
- Dan Abramov — “A Complete Guide to useEffect” (classic, still relevant).
In the book — not on the site
Each topic has an 🧠 Under-the-hood subsection — the algorithm, the data structures, what React DevTools surfaces, debugging recipes. Plus a 🧪 hands-on lab per chapter with a starter repo. Reserved for the book.