useeffect-cleanup
$npx skills add blunotech-dev/agents --skill useeffect-cleanupAudit useEffect hooks for proper cleanup, including removing listeners, canceling subscriptions, clearing timers, aborting fetches, and disconnecting observers. Use when debugging memory leaks, duplicate effects, or state updates on unmounted components.
| name | description | category |
|---|---|---|
| useeffect-cleanup | Audit useEffect hooks for proper cleanup, including removing listeners, canceling subscriptions, clearing timers, aborting fetches, and disconnecting observers. Use when debugging memory leaks, duplicate effects, or state updates on unmounted components. | React |
useEffect Cleanup Auditor
Phase 1 — Identify What Needs Cleanup
Not every effect needs cleanup. Scan for these and flag every one missing a return:
| Setup call | Required cleanup |
|---|---|
addEventListener | removeEventListener — exact same fn reference |
setTimeout | clearTimeout |
setInterval | clearInterval |
fetch / axios / any HTTP | AbortController.abort() |
new WebSocket | ws.close() |
new IntersectionObserver | observer.disconnect() |
new ResizeObserver | observer.disconnect() |
new MutationObserver | observer.disconnect() |
eventEmitter.on / .subscribe | .off() / .unsubscribe() |
setInterval inside a recursive setTimeout | clear the current timeout id, not a stale one |
Effects that only compute, set state, or call stable setters need no cleanup.
Phase 2 — Non-Obvious Failure Modes
2.1 addEventListener With an Inline Arrow Creates an Unremovable Listener
// ❌ anonymous arrow — removeEventListener does nothing, new listener added every re-render
useEffect(() => {
window.addEventListener('resize', () => setWidth(window.innerWidth));
}, []);
// ✅ named reference — same fn identity required for removal
useEffect(() => {
const handler = () => setWidth(window.innerWidth);
window.addEventListener('resize', handler);
return () => window.removeEventListener('resize', handler);
}, []);
The listener must be the same reference passed to both add and remove. Even extracting to a useCallback outside the effect doesn't help if the callback is recreated between registration and removal — define it inside the effect.
2.2 AbortController — What Actually Gets Aborted and What Doesn't
abort() cancels the network request (stops bandwidth), but the fetch promise rejects with a DOMException. You must catch it specifically, or you'll log an error on every normal unmount.
useEffect(() => {
const controller = new AbortController();
async function load() {
try {
const res = await fetch(url, { signal: controller.signal });
const data = await res.json();
setData(data); // safe — only reached if not aborted
} catch (err) {
if (err.name === 'AbortError') return; // expected, not a real error
setError(err);
}
}
load();
return () => controller.abort();
}, [url]);
What AbortController does NOT cover: axios (use CancelToken or pass signal with axios ≥0.22), custom polling, WebSocket messages already in-flight. Each needs its own cancellation mechanism.
2.3 The Race Condition AbortController Doesn't Fix
Aborting the fetch prevents the network round-trip, but if you're setting state conditionally after an await, a second effect run can start before cleanup fires (React 18 strict mode fires effects twice in dev; concurrent mode can interrupt). Add an ignored flag for anything after an await that isn't already guarded by the signal:
useEffect(() => {
let ignored = false;
const controller = new AbortController();
async function load() {
try {
const res = await fetch(url, { signal: controller.signal });
const data = await res.json();
if (!ignored) setData(data); // guards against out-of-order responses
} catch (err) {
if (err.name !== 'AbortError' && !ignored) setError(err);
}
}
load();
return () => {
ignored = true;
controller.abort();
};
}, [url]);
Use both: AbortController for the network, ignored flag for any state update after an await.
2.4 Timers — clearTimeout on the Right ID
A recursive setTimeout pattern reassigns the id on every tick. You must capture the current id in the cleanup closure, not a stale outer variable.
// ❌ id is reassigned; cleanup clears the first id, not the last
useEffect(() => {
let id: ReturnType<typeof setTimeout>;
const poll = () => { id = setTimeout(poll, 1000); };
poll();
return () => clearTimeout(id); // only clears whatever id was at cleanup time
}, []);
// ✅ use a ref to always hold the current id
useEffect(() => {
const idRef = { current: 0 as ReturnType<typeof setTimeout> };
const poll = () => { idRef.current = setTimeout(poll, 1000); };
poll();
return () => clearTimeout(idRef.current);
}, []);
2.5 Observer Pattern — disconnect vs unobserve
IntersectionObserver and ResizeObserver have two methods:
observer.unobserve(element)— stops watching one element but keeps the observer aliveobserver.disconnect()— stops everything
In cleanup, always call disconnect() unless you're sharing one observer across multiple components (rare; requires a registry pattern). Calling unobserve in cleanup is a common partial fix that leaks the observer instance itself.
2.6 Cleanup Runs on Every Re-Run, Not Just Unmount
This is the most misunderstood thing about cleanup. If the dep array is [userId], the cleanup runs every time userId changes, before the next effect setup. A cleanup that calls ws.close() will close the socket on every re-render if the socket is unstable. Fix the dep (see useeffect-dependencies skill) before assuming the cleanup is the problem.
Phase 3 — Execution
- Find every
useEffectin scope. - Check each for a
returnstatement. - For effects without a return: identify what was opened/registered. Apply the matching cleanup from Phase 1.
- For effects with a return: verify the cleanup actually matches the setup (same fn reference for listeners, correct id for timers, correct signal for fetch).
- Check if any
asynclogic after anawaitsets state without anignoredguard. - Note any effect where cleanup runs on dep change — confirm that re-setup is intentional and cheap.
Phase 4 — Output
Produce:
- Fixed effect(s) with cleanup added or corrected
- One line per fix: what was missing and what failure mode it caused (memory leak / duplicate listener / stale update / race condition)
- If
AbortErrorhandling was missing, include the catch guard - Flag if an
ignoredflag is needed in addition toAbortController
Do not produce: cleanup that only calls unobserve when disconnect is correct, cleanup that references a stale timer id, or AbortController without the AbortError catch.