e added via addEventListener.
// Leaks: adds a new listener on every render, never removes any
function WindowSize() {
const [size, setSize] = useState({ width: window.innerWidth });
useEffect(() => {
const handler = () => setSize({ width: window.innerWidth });
window.addEventListener("resize", handler);
// no cleanup: listener accumulates with every render
});
return <div>{size.width}px</div>;
}
// Correct: cleanup removes the specific handler that was added
function WindowSize() {
const [size, setSize] = useState({ width: window.innerWidth });
useEffect(() => {
const handler = () => setSize({ width: window.innerWidth });
window.addEventListener("resize", handler);
return () => window.removeEventListener("resize", handler);
}, []); // empty dep array: runs once, cleanup runs on unmount
return <div>{size.width}px</div>;
}
2. Timers: Explicit Cancellation
setInterval and setTimeout create persistent references to callbacks. Without clearInterval or clearTimeout, these callbacks execute indefinitely, holding references to component state.
// Leaks: interval keeps running after component unmounts
function LiveClock() {
const [time, setTime] = useState(new Date());
useEffect(() => {
const id = setInterval(() => setTime(new Date()), 1000);
return () => clearInterval(id); // this is required
}, []);
return <div>{time.toLocaleTimeString()}</div>;
}
3. Subscriptions: WebSocket/SSE Lifecycle Management
External subscriptions like WebSockets or EventSource (SSE) maintain network connections and event handlers. These must be closed in the cleanup function to release resources.
// Leaks: opens a new WebSocket on every mount, never closes it
function LiveFeed() {
const [messages, setMessages] = useState([]);
useEffect(() => {
const ws = new WebSocket("wss://api.example.com/feed");
ws.onmessage = (event) => {
setMessages((prev) => [...prev, JSON.parse(event.data)]);
};
// no ws.close() connection and handler stay alive after unmount
}, []);
return ;
}
// Correct
function LiveFeed() {
const [messages, setMessages] = useState([]);
useEffect(() => {
const ws = new WebSocket("wss://api.example.com/feed");
ws.onmessage = (event) => {
setMessages((prev) => [...prev, JSON.parse(event.data)]);
};
return () => ws.close();
}, []);
return ;
}
4. Stale Closures: Using useRef for Current Values
When event handlers capture state via closures, missing dependencies in useEffect can cause handlers to retain stale state indefinitely. This retains old object graphs. Use useRef to access current values without re-registering listeners.
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const handler = () => {
// This closure captures `count` at the time the effect ran
// If count is 0 when this effect ran, it will always log 0
console.log("current count:", count);
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, []); // BUG: `count` in deps array should be here
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}
function Counter() {
const [count, setCount] = useState(0);
const countRef = useRef(count);
countRef.current = count; // always in sync
useEffect(() => {
const handler = () => console.log("current count:", countRef.current);
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, []); // countRef never changes, handler never needs re-registration
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}
5. Global Caches: WeakMap for Unbounded Growth
Module-level Map and Set instances act as GC roots. Storing component data or request results in these structures prevents garbage collection indefinitely. Replace Map with WeakMap when keys are objects, or implement explicit eviction strategies.
// In a module this cache lives for the lifetime of the app
const responseCache = new Map();
async function fetchUser(id) {
if (responseCache.has(id)) {
return responseCache.get(id);
}
const data = await fetch(`/api/user/${id}`).then(r => r.json());
responseCache.set(id, data);
return data;
}
Solution: Use WeakMap for object-keyed caches or primitive keys with bounded size.
// Solution: WeakMap allows GC to reclaim entries when keys are no longer referenced elsewhere
const responseCache = new WeakMap();
async function fetchUser(userObj) {
if (responseCache.has(userObj)) {
return responseCache.get(userObj);
}
const data = await fetch(`/api/user/${userObj.id}`).then(r => r.json());
responseCache.set(userObj, data);
return data;
}
6. Async Cleanup: AbortController Pattern
For fetch requests or async operations, use AbortController to cancel pending operations during cleanup, preventing state updates on unmounted components and releasing network resources.
useEffect(() => {
const controller = new AbortController();
const fetchData = async () => {
try {
const response = await fetch('/api/data', { signal: controller.signal });
const data = await response.json();
setData(data);
} catch (error) {
if (error.name !== 'AbortError') {
console.error('Fetch error:', error);
}
}
};
fetchData();
return () => controller.abort();
}, []);
Pitfall Guide
- Handler Reference Mismatch: Creating a new anonymous function in
removeEventListener fails to remove the original listener. Always store the handler in a variable and pass the exact same reference to both add and remove methods.
- Global
Map/Set as GC Roots: Module-scoped collections prevent garbage collection of all stored values. Use WeakMap for object keys or implement LRU eviction for primitive keys to prevent unbounded growth.
- Stale Closures in Dependencies: Omitting state variables from
useEffect dependency arrays causes handlers to capture outdated values. This retains old state objects and causes logic errors. Use useRef for current values or include dependencies correctly.
- Async Resource Lifecycle Mismatch: WebSockets,
setInterval, and fetch requests often outlive the component. Always implement cleanup functions to close connections, clear timers, and abort signals.
- StrictMode Double-Invocation Illusion: React's StrictMode double-invokes effects in development. This can mask missing cleanup logic if developers do not inspect heap snapshots carefully. Treat double-invocation as a stress test for cleanup functions.
WeakRef/WeakMap Misapplication: WeakMap keys must be objects; primitive keys will throw errors. WeakRef values can be garbage collected at any time, requiring null checks. Misusing these can lead to unexpected undefined values or type errors.
- Ignoring
AbortError Handling: When using AbortController, fetch throws an AbortError on cancellation. Failing to catch and ignore this error results in noisy console warnings. Always check error.name !== 'AbortError' in catch blocks.
Deliverables
π React Memory Leak Prevention Blueprint
A comprehensive guide covering:
- Retained Object Graph Analysis: Step-by-step instructions for using Chrome DevTools Heap Snapshots to diff allocations and identify leak chains.
- Cleanup Pattern Matrix: Decision tree for selecting the appropriate cleanup strategy (
useEffect return, AbortController, WeakRef, WeakMap) based on resource type.
- Audit Checklist: Pre-deployment checklist for verifying all side effects, subscriptions, and caches have proper lifecycle management.
β
useEffect Cleanup Checklist
βοΈ Configuration Templates
useAsyncEffect Hook: A reusable hook template with built-in AbortController and mounted state checking.
WeakMapCache Utility: A generic cache wrapper using WeakMap for safe object-keyed caching.
- Heap Snapshot Diff Script: A snippet for automating heap diff analysis in CI/CD pipelines to detect memory regressions.