React useEffect Cleanup Not Working - Intervals and Listeners Keep Stacking
6 min read
“One reset, and the ticker doubles its speed. Two resets, triples. Nothing was ever removed - every run of the effect left its interval running and quietly started another one on top.”
The function returned from a useEffect callback is the cleanup
function. React calls it in exactly two situations: right before the effect
runs again (because a dependency changed), and when the component unmounts.
The point of cleanup is to undo whatever setup the effect just did, so that
the next run - or the component's disappearance - starts from a clean
slate. An interval that was started should be cleared. A listener that was
attached should be removed. If you don't return that function, React has
nothing to call, and "undo the last setup" never happens.
handleReset flips running to false and then, fifty milliseconds later,
back to true. Each of those is a change to running - which is the
effect's dependency - so the effect re-runs both times.
The first time running becomes false, the effect's body hits
if (!running) return and does nothing further. The second time,
running is true again, so a fresh setInterval is created.
Here's the stack forming: on mount, interval #1 starts. Click Reset -
interval #1 is never cleared, because there's no cleanup function - and
interval #2 starts. Click Reset again, interval #3 starts, while #1 and #2
are both still ticking in the background.
Symptom
Each Reset doesn't just restart the count - it permanently increases how
fast it climbs afterward. One reset: counts by 2 each second. Two resets:
counts by 3. The increase per reset matches exactly how many intervals are
now running concurrently, all incrementing the same state.
useEffect(() => { if (!running) return; const id = setInterval(() => { setSeconds((s) => s + 1); }, 1000); return () => clearInterval(id);}, [running]);
One line. Now, before this effect runs again - which happens every time
running changes - React calls the previous run's cleanup first.
clearInterval(id) stops that specific interval. Then the new run starts
its own interval, on a clean slate.
At any given moment, exactly one interval is alive. Reset stops the current
one and starts a fresh one at the current speed - which is the same speed
every time, because there's never more than one.
Every time clicks changes, this effect re-runs and calls
window.addEventListener("keydown", handleKey) again. Each call attaches
another listener. The old ones are never removed - window now has two,
three, four keydown listeners stacked up, one per click on "Re-render."
Press a key once. Every listener fires. keyCount jumps by however many
listeners happen to be attached - which grows by one every time someone
clicks the unrelated "Re-render" button.
useEffect(() => { function handleKey() { setKeyCount((k) => k + 1); } window.addEventListener("keydown", handleKey); return () => window.removeEventListener("keydown", handleKey);}, [clicks]);
Same fix, same shape: return () => window.removeEventListener(...).
Before each re-run, the previous listener is removed. Exactly one listener
is ever attached at a time, so one keypress is always exactly one increment.
The Sensei's Hint
The question to ask for any effect that calls a *.add* or *.set* style
API - addEventListener, setInterval, setTimeout, subscribing to
anything - is "what's the matching remove/clear/unsubscribe call, and
did I return a function that makes it?" If the setup has an undo, the
effect almost always needs to perform it in cleanup.
This last one is different - it's not about missing cleanup, but about
writing it in a way that doesn't actually work as cleanup at all.
useEffect(() => { const id = setInterval(() => { setCount((c) => c + 1); }, 1000); return clearInterval(id);}, []);
This looks like it has a return statement that clears the interval. It does
not. return clearInterval(id)callsclearInterval(id) immediately -
right now, in the same synchronous pass where the interval was just
created - and then returns whatever clearInterval returns, which is
undefined.
So the actual sequence is: create the interval, immediately cancel it,
return undefined (which React treats as "no cleanup function"). The
counter never ticks even once. Count: 0, forever.
return () => clearInterval(id) is the fix - small change, easy to miss:
wrapping the call in an arrow function. This returns a function that, when
called later, will clear the interval. React holds onto that function and
calls it
only when this effect is about to re-run or the component unmounts. The
interval is left alone in the meantime, and ticks normally.
Constraints
return someCleanupCall() runs someCleanupCall now and returns its
result. return () => someCleanupCall() returns a function that will run
someCleanupCall later. For cleanup, you almost always want the second
form - a function for React to call, not a result of calling something
yourself.
"Timer That Stacks" is the clearest demonstration of accumulation - watch
the speed increase with every Reset, then add the one-line return () => clearInterval(id) and watch it stop accumulating. "Listener That Multiplies"
is the same accumulation with addEventListener, useful for seeing that this
isn't a setInterval-specific issue - it's anything with a matching
teardown. "Counter Never Starts Ticking" is the odd one out: nothing
stacks, nothing accumulates, the counter just never starts - because the
return clearInterval(id) vs return () => clearInterval(id) distinction is
easy to misread and breaks the effect before it ever does anything.