Reviewing a useEffect for the Cleanup It Forgot
The listener, the timer and the subscription all outlive the component that created them, and the leak is invisible on the first mount and obvious on the tenth.
The bug report was a search box that fired its network request twice after a navigation, then three times after the next, a counter climbing with each visit, which is the signature of a subscription that is created on mount and never destroyed, accumulating one listener per mount, each of which fires on the next event. The code review that merged it had read the effect and seen a sensible subscription, and the review's failure was that it asked what the effect does and never what the effect leaves behind, which is the only question that matters for an effect, because an effect is the one place in a component where the lifetime of the work can exceed the lifetime of the component.
I have hit this enough times that I now check for it before I check for anything clever.
This is the review guide for the missing cleanup, the family of leaks where the effect's teardown is absent, and the review that catches it.
This was React 19.1 with a dev build that warns on some of these in StrictMode, and production, which does not warn, is where the tenth mount lives.
The effect is a lease, not a call
A render is a pure computation that dies when it is replaced, but an effect is a side effect with a lifetime: it runs after mount, and it is supposed to return a cleanup that runs before unmount and before each re run, and the cleanup is the lease's termination clause. An effect without a cleanup is a lease that never ends, and the resource it holds, the listener, the timer, the socket, the subscription, outlives the component, firing into a dead component or, worse, into a new one, accumulating per mount.
The review question is therefore structural, not behavioural: for every effect, what does it acquire, and where is the release, and if the release is absent, the effect is a leak with a mount counter, per reviewing shared state by asking what happens under contention in spirit, asking not what happens once but what happens on the tenth repetition.
The family of forgotten cleanups
The leaks cluster by resource, and the review should check each by name.
Event listeners. addEventListener without the matching removeEventListener in the cleanup is the classic, and the bug's doubling search was this, the window listener added on every mount, removed never. The review greps the pair, and a listener without a removal in the same effect is flagged, because the removal must live in the cleanup, not elsewhere, or the lease is unbounded.
Timers. setTimeout and setInterval started in an effect must be cleared in the cleanup, and the interval is the dangerous one, because a forgotten interval fires forever, into an unmounted component, calling setState on the dead, which React now warns about but the work, the fetch, the compute, still runs, burning CPU per leaked interval, a fleet of ghosts on a clock.
Subscriptions and sockets. An external store subscription, a websocket, an event emitter, subscribed in the effect and not unsubscribed in the cleanup, is the accumulating listener family with a network connection attached, and the socket that outlives the component is also a server side resource leak, which makes the client bug a server bill.
Aborted fetches. A fetch started in an effect without an AbortController in the cleanup leaves the request running after unmount, and its response then writes to a dead component, and worse, races the next mount's fetch, so the stale response lands after the fresh one, which is the race the debounce that dropped the last keystroke family lives in, at the network level. The cleanup that aborts is the cancellation half of the lease.
Why the first mount hides it
The leak is invisible on the first mount because one listener behaves exactly like a correct one, and the doubling appears only on the second visit, and the obvious failure only on the tenth, so local testing, which mounts once, shows nothing, and the dev StrictMode double invocation, which mounts twice, is the only built in witness, and it warns rather than fails, and production has no witness at all. The review is therefore the first gate where the repetition is considered, and the question to ask is the tenth mount question: after ten mounts and unmounts, how many listeners, timers, sockets exist, and if the answer is ten where it should be one, the cleanup is missing.
The review checklist
For every effect in the diff, the review asks four questions, and a no on the first two with a yes on the acquisition is a block.
What does it acquire, listener, timer, subscription, socket, fetch. Where is the release, and is it in the returned cleanup, not in some other effect or a hope. Does the cleanup run on re run as well as unmount, because an effect with changing deps re runs, and a cleanup that only handles unmount leaks on every dep change. And does the effect guard the stale write, the abort or the mounted check, so the async completion after unmount is discarded, because the cleanup that aborts is the guard.
The test that catches it
The regression test mounts the component, unmounts it, and asserts the listener count on the target returns to baseline, or that the emitter's listener count is zero, and repeats the cycle ten times, asserting no growth, because the leak is a slope, and the test asserts the slope is flat. The test is the tenth mount made visible, and it fails on the original code in milliseconds, which is the review's executable form.
The rule
An effect is a lease on a resource that can outlive the component, and the cleanup is the termination clause, so the review asks what the effect acquires and where the release lives, and treats a listener, timer, subscription, socket or fetch without a cleanup as an accumulating leak, invisible on the first mount and obvious on the tenth.
The search box that fired twice was not a logic bug. It was an unterminated lease, renewed on every visit, and the review that reads the effect without reading the cleanup is reading the contract's first page only, which is where all the pleasant clauses live, and the termination, as ever, is on the last.
Published on debugly.dev by Rohit Bhadani | Founder of Krova Cloud