modern-react-spa

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 / sessionStorage reads 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:

  1. Chrome DevTools → Performance Monitor. Watch “JS event listeners” count over time. Should be roughly stable; growing monotonically → leak.
  2. Memory profiler. Take a heap snapshot. Navigate around the app. Take another. Diff. Detached DOM nodes / closures held by listeners stand out.
  3. 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

  1. Adding count to a useEffect’s deps to fix a stale closure → the effect re-runs constantly.
  2. suppressHydrationWarning on an ancestor instead of the exact element.
  3. Adding event listeners without cleanup, then debugging “phantom keyboard handlers.”
  4. Writing to a store from inside render() → render loop.
  5. Network race “fixed” with a setTimeout — non-deterministic; still wrong.
  6. 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.

Topics in this chapter (5)
  1. 35.1 Stale closures in hooks
  2. 35.2 Hydration mismatches
  3. 35.3 Memory leaks (event listeners, subscriptions)
  4. 35.4 Render-loop traps with global state
  5. 35.5 Reproducing race conditions deterministically