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 |