Search across all documentation pages
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
// The core pattern: every useEffect that allocates must clean up
useEffect(() => {
// ALLOCATE: subscription, listener, timer, fetch
const controller = new AbortController();
const intervalId = setInterval(pollData, 5000);
window.addEventListener("resize", handleResize);
fetchData({ signal: controller.signal });
// CLEANUP: runs on unmount and before re-running effect
return () => {
controller.abort();
clearInterval(intervalId);
window.removeEventListener("resize", handleResize);
};
}, []);When to reach for this: When your app's memory usage grows over time during navigation, when Chrome DevTools shows an increasing JS heap size, or when users report the app becoming slower the longer they use it.
// ---- BEFORE: Component with 5 memory leaks ----
function LiveDashboard({ userId }: { userId: string }) {
const [data, setData] = useState<DashboardData | null>(null);
const [mousePos, setMousePos] = useState({ x: 0, y: 0 });
const [notifications, setNotifications] = useState<string[]>([]);
const chartRef = useRef<HTMLCanvasElement>(null);
// LEAK 1: Fetch without abort - updates state after unmount
useEffect(() => {
async function loadData() {
const res = await fetch(`/api/dashboard/${userId}`);
const json = await res.json();
setData(json); // May run after unmount!
}
loadData();
}, [userId]);
// LEAK 2: Interval never cleared - keeps running after unmount
useEffect(() => {
setInterval(async () => {
const res = await fetch(`/api/dashboard/${userId}/live`);
const json = await res.json();
setData(json); // Runs forever, even after navigating away
}, 5000);
}, [userId]);
// LEAK 3: Event listener never removed
useEffect(() => {
window.addEventListener("mousemove", (e) => {
setMousePos({ x: e.clientX, y: e.clientY });
});
}, []);
// LEAK 4: WebSocket never closed
useEffect(() => {
const ws = new WebSocket(`wss://api.example.com/ws/${userId}`);
ws.onmessage = (event) => {
setNotifications((prev) => [...prev, event.data]);
};
// No cleanup - WebSocket stays open
}, [userId]);
// LEAK 5: Canvas context and large data retained via closure
useEffect(() => {
const ctx = chartRef.current?.getContext("2d");
const hugeDataSet = new Float64Array(1_000_000); // 8MB allocation
function drawChart() {
// Closure retains hugeDataSet even after component unmounts
ctx?.clearRect(0, 0, 800, 400);
// ... draw using hugeDataSet
}
drawChart();
// hugeDataSet is never released because drawChart retains a reference
}, [data]);
return (
<div>
<canvas ref={chartRef} width={800} height={400} />
<p>Mouse: {mousePos.x}, {mousePos.y}</p>
<ul>
{notifications.map((n, i) => (
<li key={i}>{n}</li>
))}
</ul>
</div>
);
}
// ---- AFTER: All 5 leaks fixed - proper cleanup on every effect ----
function LiveDashboard({ userId }: { userId: string }) {
const [data, setData] = useState<DashboardData | null>(null);
const [mousePos, setMousePos] = useState({ x: 0, y: 0 });
const [notifications, setNotifications] = useState<string[]>([]);
const chartRef = useRef<HTMLCanvasElement>(null);
// FIX 1: AbortController cancels fetch on unmount or userId change
useEffect(() => {
const controller = new AbortController();
async function loadData() {
try {
const res = await fetch(`/api/dashboard/${userId}`, {
signal: controller.signal,
});
const json = await res.json();
setData(json);
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") {
// Expected when component unmounts - not an error
return;
}
throw err;
}
}
loadData();
return () => controller.abort();
}, [userId]);
// FIX 2: Interval cleared on unmount, AbortController for each poll
useEffect(() => {
const controller = new AbortController();
const intervalId = setInterval(async () => {
try {
const res = await fetch(`/api/dashboard/${userId}/live`, {
signal: controller.signal,
});
const json = await res.json();
setData(json);
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") return;
console.error("Poll failed:", err);
}
}, 5000);
return () => {
clearInterval(intervalId);
controller.abort();
};
}, [userId]);
// FIX 3: Named handler + removeEventListener on cleanup
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
setMousePos({ x: e.clientX, y: e.clientY });
};
window.addEventListener("mousemove", handleMouseMove);
return () => window.removeEventListener("mousemove", handleMouseMove);
}, []);
// FIX 4: WebSocket closed on cleanup
useEffect(() => {
const ws = new WebSocket(`wss://api.example.com/ws/${userId}`);
ws.onmessage = (event) => {
setNotifications((prev) => [...prev, event.data]);
};
ws.onerror = (error) => {
console.error("WebSocket error:", error);
};
return () => {
ws.close();
};
}, [userId]);
// FIX 5: Cleanup nullifies large data, uses ref for mutable binding
useEffect(() => {
const ctx = chartRef.current?.getContext("2d");
let hugeDataSet: Float64Array | null = new Float64Array(1_000_000);
function drawChart() {
if (!hugeDataSet || !ctx) return;
ctx.clearRect(0, 0, 800, 400);
// ... draw using hugeDataSet
}
drawChart();
return () => {
hugeDataSet = null; // Release 8MB - allows garbage collection
};
}, [data]);
return (
<div>
<canvas ref={chartRef} width={800} height={400} />
<p>Mouse: {mousePos.x}, {mousePos.y}</p>
<ul>
{notifications.map((n, i) => (
<li key={i}>{n}</li>
))}
</ul>
</div>
);
}What this demonstrates:
clearInterval stops polling when the component is no longer visibleremoveEventListener cleanupuseEffect runs in two scenarios: (1) before the effect re-runs due to dependency changes, and (2) when the component unmounts. This is where all resource deallocation must happen.setState on an unmounted component is a no-op in React 18+, but the closure itself prevents garbage collection of everything it references.useEffect without cleanup means each re-mount (navigation) adds another listener. After 10 navigations, you have 10 listeners all firing on every event.AbortController for multiple concurrent fetches:
useEffect(() => {
const controller = new AbortController();
async function loadAll() {
const [users, orders, metrics] = await Promise.all([
fetch("/api/users", { signal: controller.signal }),
fetch("/api/orders", { signal: controller.signal }),
fetch("/api/metrics", { signal: controller.signal }),
]);
// One abort cancels all three
setData({
users: await users.json(),
orders: await orders.json(),
metrics: await metrics.json(),
});
}
loadAll().catch((err) => {
if (err.name !== "AbortError") throw err;
});
return () => controller.abort();
}, []);WeakRef for optional references:
// Advanced: WeakRef allows garbage collection of the referenced object
function useWeakCallback<T extends object>(target: T, callback: (target: T) => void) {
const weakRef = useRef(new WeakRef(target));
useEffect(() => {
const intervalId = setInterval(() => {
const obj = weakRef.current.deref();
if (obj) {
callback(obj);
} else {
// Object was garbage collected - stop polling
clearInterval(intervalId);
}
}, 1000);
return () => clearInterval(intervalId);
}, [callback]);
}Detecting leaks with heap snapshots:
1. Open Chrome DevTools -> Memory tab
2. Take Heap Snapshot (Snapshot 1 - baseline)
3. Navigate to the suspected leaky page
4. Navigate away from the page
5. Click the garbage collection button (trash can icon)
6. Take Heap Snapshot (Snapshot 2)
7. Select Snapshot 2 -> Change view to "Comparison"
8. Sort by "Delta" column - objects with positive deltas are potential leaks
9. Look for: Detached HTMLDivElement, EventListener, Closure, Array
AbortController and AbortSignal types are built into the DOM lib. No additional types needed.void or undefined (useEffect forbids returning anything else).WeakRef<T> requires T extends object. Primitives cannot be weakly referenced.FinalizationRegistry<T> callback receives the held value of type T when the target is collected.Anonymous functions prevent cleanup - window.addEventListener("resize", () => {...}) cannot be removed because removeEventListener requires the same function reference. Fix: Always use named functions or store the reference in a variable.
Strict Mode double-mounts masking leaks - React 18+ Strict Mode mounts, unmounts, and remounts every component in development. This can make leaks appear as "normal" behavior. Fix: A properly cleaned-up component works correctly through Strict Mode's mount cycle. If you see duplicated listeners or connections, your cleanup is incomplete.
Missing dependency in effect causes stale cleanup - If the cleanup function references a variable that is not in the dependency array, it may close over a stale value. Fix: Include all referenced variables in the dependency array, or use refs for mutable values that should not trigger re-runs.
Third-party library cleanup - Libraries like chart.js, mapbox-gl, or video players allocate internal resources. Fix: Call the library's destroy/dispose method in the cleanup function: chart.destroy(), map.remove(), player.dispose().
React 18+ no longer warns about state updates on unmounted components - The warning was removed because it produced too many false positives. But the underlying leak still exists. Fix: Use AbortController and cleanup functions regardless of whether React warns you.
Timers with closure over growing arrays - setInterval callbacks that push to an array create an ever-growing data structure even if the component re-mounts with a fresh state. Fix: Clear the interval in cleanup and ensure the closure does not reference accumulators from previous mounts.
| Approach | Trade-off |
|---|---|
useEffect cleanup | Built-in; requires manual discipline for every effect |
| AbortController | Standard API; only works with fetch and APIs that accept AbortSignal |
| TanStack Query | Automatic fetch lifecycle management; adds dependency |
| SWR | Automatic request deduplication and cleanup; adds dependency |
use hook (React 19) | Promise-based; works with Suspense, no manual cleanup needed |
| RxJS | Observable subscriptions with automatic cleanup; heavy dependency |
| Zustand subscriptions | Store-managed; cleanup via subscribe return value |
Every useEffect that allocates resources must return a cleanup function:
useEffect(() => {
const controller = new AbortController();
fetchData({ signal: controller.signal });
return () => controller.abort();
}, []);The cleanup runs on unmount and before the effect re-runs.
The warning was removed because it produced too many false positives. However, the underlying leak still exists -- the closure retains references to the component's state and prevents garbage collection. Use AbortController and cleanup functions regardless.
Use AbortController and pass its signal to fetch:
useEffect(() => {
const controller = new AbortController();
fetch(url, { signal: controller.signal })
.then((r) => r.json())
.then(setData)
.catch((err) => {
if (err.name === "AbortError") return;
throw err;
});
return () => controller.abort();
}, [url]);window.addEventListener("resize", () => {...}) cannot be removed because removeEventListener requires the exact same function reference.
Fix: Store the handler in a variable:
const handleResize = () => { /* ... */ };
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);React 18+ Strict Mode mounts, unmounts, and remounts every component in development. This can make leaks appear as "normal" behavior. A properly cleaned-up component works correctly through this cycle -- if you see duplicated listeners or connections, your cleanup is incomplete.
Nullify large data in the cleanup function to allow garbage collection:
useEffect(() => {
let hugeData: Float64Array | null = new Float64Array(1_000_000);
drawChart(hugeData);
return () => { hugeData = null; };
}, [data]);Close the WebSocket in the cleanup function:
useEffect(() => {
const ws = new WebSocket(`wss://api.example.com/ws/${id}`);
ws.onmessage = (event) => setData(event.data);
return () => ws.close();
}, [id]);WeakRef<T> requires T extends object. Primitives (strings, numbers, booleans) cannot be weakly referenced. The cleanup function return type from useEffect must be void or undefined.
Call the library's destroy/dispose method in the cleanup function:
useEffect(() => {
const chart = new Chart(canvasRef.current, config);
return () => chart.destroy();
}, [config]);Libraries that manipulate the DOM directly retain detached nodes if not properly destroyed.
Yes. Pass the same signal to all fetch calls. Calling controller.abort() once cancels every request using that signal:
const [users, orders] = await Promise.all([
fetch("/api/users", { signal: controller.signal }),
fetch("/api/orders", { signal: controller.signal }),
]);The callback continues pushing to the array even if the component re-mounts with fresh state. The old closure retains the old array, creating an ever-growing data structure. Always clear the interval in cleanup.
Reviewed by Chris St. John·Last updated Jul 10, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥