Cleanup and stale closures
Every subscription, interval, listener, and fetch an effect starts must be torn down in its return, and effects capture the render that created them — so fix stale closures with correct deps plus functional updates or a ref, never by silencing the lint rule.
An effect is the one place in React where you reach outside the render: you open a socket, start a timer, attach a listener, fire a request. Render is pure and disposable — but the things an effect starts are not. They outlive the render that created them, and unless you give React a way to stop them, they pile up: a new interval every keystroke, a listener that fires after unmount, a stale request that overwrites the fresh one.
Two failure modes hide here, and they’re the subtlest bugs in React. The first is missing cleanup: the effect starts something and never stops it, so it leaks and races when deps change. The second is the stale closure: the effect captured values from the render that created it, so it acts on yesterday’s state. Both ship green in CI and surface as a flaky timer or a leak in production.
After this lesson you can write the cleanup return for any effect that starts a subscription, interval, listener, or fetch (including an AbortController abort); explain why an effect captures the values of the render that created it and how [] deps freeze that closure forever; fix a stale-closure setInterval with a functional update or a ref instead of silencing exhaustive-deps; and name the two opposite failure modes — a silenced stale closure versus an over-stuffed dep array that re-subscribes every render.
Whatever an effect starts, its cleanup must stop — the return function is not optional decoration. React calls the cleanup before the effect re-runs (because a dep changed) and once more on unmount. If you skip it, the old subscription, interval, or listener keeps running alongside the new one. The mental model: setup and cleanup come in pairs, and at any moment exactly one subscription should be live.
useEffect(() => {
const socket = createConnection(roomId);
socket.connect();
return () => socket.disconnect(); // pairs with connect
}, [roomId]);When roomId changes, React runs cleanup for the old room then setup for the new one. Drop the return and every room change leaks another open connection — and every connection still fires its handlers into a component that has moved on.
An effect captures the values of the render that created it — that’s the stale closure, and it’s a feature, not a bug. The function you pass to useEffect closes over the props and state from that render. If the deps array doesn’t list a value the effect reads, the effect keeps using the value frozen at the render it last ran on. With [], that’s the very first render, forever.
function Poller({ onTick }: { onTick: (n: number) => void }) {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
// BUG: `count` is frozen at 0 — this closure was created on the
// first render and [] never lets React make a fresh one.
setCount(count + 1); // always 0 + 1 → stuck at 1
}, 1000);
return () => clearInterval(id);
}, []); // lies: the effect reads `count` but doesn't list it
return <p>{count}</p>;
}The interval runs forever, but it forever computes 0 + 1. The closure is correct React semantics; the deps are the lie.
Fix a stale closure by removing the dependency, not by hiding it: a functional update, or a ref for values you only read. The interval doesn’t actually need the current count value — it needs to increment whatever the latest count is. A functional update expresses exactly that, and now the effect reads no state, so [] is honest.
useEffect(() => {
const id = setInterval(() => {
setCount((c) => c + 1); // React supplies the latest value
}, 1000);
return () => clearInterval(id);
}, []); // honest now — the effect closes over nothing reactiveWhen you must read the latest value (not just transform it) inside a long-lived callback — say the freshest onTick prop — write it to a ref and read ref.current, so the interval is never recreated yet always sees today’s value:
const onTickRef = useRef(onTick);
useEffect(() => { onTickRef.current = onTick; }); // every render, keep it fresh
useEffect(() => {
const id = setInterval(() => onTickRef.current(Date.now()), 1000);
return () => clearInterval(id);
}, []); // interval created once; ref bridges to the latest propA fetch is a subscription too: cancel the in-flight request in cleanup with AbortController, or fast deps race slow responses. When the dep changes faster than the network responds, two requests are in flight and the slower one can resolve last, overwriting the correct data. Cleanup aborts the stale request so only the current one can commit.
useEffect(() => {
const ctrl = new AbortController();
fetch(`/api/users/${userId}`, { signal: ctrl.signal })
.then((r) => r.json())
.then(setUser)
.catch((err) => {
if (err.name !== "AbortError") setError(err); // ignore our own abort
});
return () => ctrl.abort(); // cancel the stale request on userId change/unmount
}, [userId]);Without the abort, switching userId from 1 → 2 → 3 can leave user 1’s response committing after user 3’s, and a setUser firing after unmount. The abort closes both holes — leak and race — in one line.
A live-search box that leaks and races — fixed in one effect. It fetches on every query change. The first version forgets both cleanup and the stale-closure trap; the second closes them.
Before — no cancellation, and a debounce timer that never clears:
function SearchResults({ query }: { query: string }) {
const [results, setResults] = useState<string[]>([]);
const [hits, setHits] = useState(0);
useEffect(() => {
// a new timer every render; old timers are never cleared → many fire
setTimeout(() => {
fetch(`/api/search?q=${query}`)
.then((r) => r.json())
.then((data) => {
setResults(data);
setHits(hits + 1); // stale: `hits` frozen at this render's value
});
}, 300);
}, [query]); // no return → no cleanup; timers + requests stack and race
return <Results items={results} count={hits} />;
}Type “react” fast and you get five overlapping timers, five requests that can resolve out of order, and a hits counter stuck near 1 because every closure reads the same frozen value. After — debounce timer cleared, request aborted, counter made functional:
function SearchResults({ query }: { query: string }) {
const [results, setResults] = useState<string[]>([]);
const [hits, setHits] = useState(0);
useEffect(() => {
const ctrl = new AbortController();
const t = setTimeout(() => {
fetch(`/api/search?q=${query}`, { signal: ctrl.signal })
.then((r) => r.json())
.then((data) => {
setResults(data);
setHits((h) => h + 1); // functional → no `hits` dependency
})
.catch((e) => { if (e.name !== "AbortError") throw e; });
}, 300);
return () => { clearTimeout(t); ctrl.abort(); }; // both resources released
}, [query]); // honest: reads only `query`; setHits is functional
return <Results items={results} count={hits} />;
}One return clears the pending debounce timer and aborts the in-flight request on every keystroke and on unmount; the functional setHits removes the only state the effect read, so [query] is now the complete and correct dep list.
▸Common mistake
The career-defining mistake is silencing the warning: you see React Hook useEffect has a missing dependency: 'count', you add // eslint-disable-next-line react-hooks/exhaustive-deps, and you ship a stale closure. The lint rule is almost never wrong — it’s telling you the effect reads a value your deps don’t list. The fix is to make the read honest (functional update, ref, or actually listing the dep), not to mute the messenger. A disabled exhaustive-deps line is a bug with a comment apologizing for itself.
▸Edge cases
The opposite over-correction is just as wrong: terrified of stale closures, you stuff every referenced value into the dep array — including a config object or an onChange function recreated inline on each render. Now the effect tears down and re-subscribes on every render: the socket reconnects constantly, the interval resets, the fetch refires in a loop. The cure is to stabilize the dependency (a functional update to drop it, useCallback/useMemo for functions and objects, or a ref for read-only values), not to thin the array by lying. Honest deps that are also stable is the target — not the longest array, and not the shortest.
An effect runs `setInterval(() => setCount(count + 1), 1000)` with `[]` deps, and the counter sticks at 1. A teammate's PR adds `count` to the dep array to fix it. What's the senior critique?
Effects are where React touches the outside world, and that world doesn’t clean up after itself. Pair every setup with cleanup: a subscription’s disconnect, an interval’s clearInterval, a listener’s removeEventListener, a fetch’s AbortController.abort — React runs the return before each re-run and on unmount, so missing cleanup means leaks and races the moment a dep changes. The other half is the stale closure: an effect captures the values of the render that created it, so a dep array that omits a value it reads freezes that value (with [], forever). Fix it by making the read honest — a functional update to drop the dependency, or a ref to read the latest value from a long-lived callback — never by disabling exhaustive-deps, which just ships the bug with an apology. And don’t over-correct into the opposite failure: an over-stuffed dep array re-subscribes every render. The target is deps that are both honest and stable, so each effect sets up once per real change and always acts on today’s values.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.