Cleanup and Race Conditions — Avoiding Stale Data

The cleanup function returned from useEffect is React’s mechanism for reversing or cancelling side effects when the component unmounts or the dependencies change. Without cleanup, you accumulate event listeners on every render, WebSocket connections stay open after navigation, and in-flight HTTP requests complete and try to update state on unmounted components. Race conditions — where multiple overlapping fetch requests complete out of order — are one of the most insidious data-fetching bugs, and the AbortController API eliminates them cleanly.

The Race Condition Problem

Race condition scenario:

1. User is on page 1: fetch(/api/posts?page=1) starts → request A
2. User quickly clicks to page 2: fetch(/api/posts?page=2) starts → request B
   → request A is still in flight!
3. request B completes first (fast server) → posts state = page 2 data ✓
4. request A completes second (slow network) → posts state = page 1 data ✗
   → User sees page 2 but data is from page 1!

The last request to complete wins — not the last request to start.
This is the race condition.

Fix: cancel the previous request when dependencies change (abort request A).
Note: React 18’s Strict Mode intentionally mounts components twice in development to help surface cleanup bugs. You may notice your data fetches running twice — this is expected in development and does not happen in production. The fix is not to remove Strict Mode, but to write proper cleanup functions. If your effect crashes or shows duplicate data on the double mount, it means the cleanup is missing or incorrect.
Tip: The AbortController pattern is the correct solution for race conditions in useEffect data fetching. Create a new AbortController at the start of each effect, pass its signal to fetch(url, { signal }), and abort it in the cleanup function. When the effect re-runs (due to dependency change), the cleanup aborts the previous request before the new one starts. The aborted request throws an AbortError which you should silently ignore in the catch block.
Warning: Forgetting to clean up event listeners is a common memory leak in React applications. Every time a component re-renders and the effect re-runs, a new event listener is added to window or document without removing the old one. After 100 renders, there are 100 listeners on the same event. Always return a cleanup function from effects that add event listeners: return () => window.removeEventListener("resize", handler).

AbortController — Fixing the Race Condition

import { useState, useEffect } from "react";

function PostFeed({ page, tag }) {
    const [posts,     setPosts]     = useState([]);
    const [isLoading, setIsLoading] = useState(true);
    const [error,     setError]     = useState(null);

    useEffect(() => {
        // Create a new AbortController for this fetch
        const controller = new AbortController();

        setIsLoading(true);
        setError(null);

        const url = new URL("/api/posts", window.location.origin);
        url.searchParams.set("page", page);
        if (tag) url.searchParams.set("tag", tag);

        fetch(url, { signal: controller.signal })   // pass the signal
            .then((res) => {
                if (!res.ok) throw new Error(`HTTP ${res.status}`);
                return res.json();
            })
            .then((data) => {
                setPosts(data.items);
                setIsLoading(false);
            })
            .catch((err) => {
                if (err.name === "AbortError") return;   // ← silently ignore abort
                setError(err);
                setIsLoading(false);
            });

        // Cleanup: abort the request when page/tag changes or component unmounts
        return () => {
            controller.abort();   // ← cancels the in-flight fetch
        };
    }, [page, tag]);

    // ...
}

Cleanup for Other Side Effects

// ── Event listener cleanup ────────────────────────────────────────────────────
useEffect(() => {
    function handleKeyPress(e) {
        if (e.key === "Escape") closeModal();
    }
    document.addEventListener("keydown", handleKeyPress);
    return () => document.removeEventListener("keydown", handleKeyPress);  // ✓
}, []);

// ── Timer cleanup ─────────────────────────────────────────────────────────────
useEffect(() => {
    const interval = setInterval(() => {
        refreshViewCount();
    }, 30_000);
    return () => clearInterval(interval);  // ✓ clear on unmount
}, []);

// ── WebSocket cleanup ─────────────────────────────────────────────────────────
useEffect(() => {
    const ws = new WebSocket(`wss://api.example.com/ws?token=${token}`);
    ws.onmessage = (e) => handleMessage(JSON.parse(e.data));
    ws.onerror   = (e) => setWsError(e);

    return () => {
        ws.close(1000, "Component unmounted");  // ✓ graceful close
    };
}, [token]);

// ── Subscription cleanup (e.g., EventEmitter) ────────────────────────────────
useEffect(() => {
    const unsubscribe = store.subscribe(setNotifications);
    return unsubscribe;   // ✓ call the returned unsubscribe function
}, []);

Common Mistakes

Mistake 1 — Not cleaning up event listeners (memory leak)

❌ Wrong — listener accumulates on every render:

useEffect(() => {
    window.addEventListener("scroll", handleScroll);
    // No return — listener never removed! Adds new one each render.
}, [someValue]);

✅ Correct — always return cleanup for listeners:

useEffect(() => {
    window.addEventListener("scroll", handleScroll);
    return () => window.removeEventListener("scroll", handleScroll);  // ✓
}, [someValue]);

Mistake 2 — Not ignoring AbortError (noisy console errors)

❌ Wrong — legitimate cancellations show as errors:

.catch((err) => { setError(err); });   // AbortError shows as error!

✅ Correct:

.catch((err) => { if (err.name === "AbortError") return; setError(err); });  // ✓

Quick Reference

Pattern Cleanup Code
Fetch (abort) const c = new AbortController(); ... return () => c.abort()
Event listener return () => target.removeEventListener(type, fn)
setInterval return () => clearInterval(id)
setTimeout return () => clearTimeout(id)
WebSocket return () => ws.close(1000)
Subscription return unsubscribeFn

🧠 Test Yourself

A user types quickly in a search box, triggering fetches for “p”, “py”, “pyt”. The “p” request is slow and arrives last. Without abort, what does the user see? With abort, what happens?