Back to the Library

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.”

In This Post

What the Return Statement Is ForStacking IntervalsThe Fix: Return the TeardownStacking ListenersThe Other Cleanup Bug: A Function That Returns `undefined`Practice These Patterns

Practice This Pattern

Blue Belt

Timer That Stacks

A stopwatch that ticks normally until Reset is clicked - then it starts jumping by 2, then 3, then 4, because every reset starts a new interval without stopping the last one.

+25 KI
Enter the Dojo
Blue Belt

Listener That Multiplies

A key counter where each keypress should add exactly 1 - after clicking a re-render button, single keypresses start adding 2, then 3, because every re-render attaches another keydown listener.

+25 KI
Enter the Dojo
Blue Belt

Counter Never Starts Ticking

A counter that should tick up every second instead sits frozen at 0 forever - the cleanup function is called immediately instead of being returned for React to call later.

+25 KI
Enter the Dojo
BugDojo
BlogFAQ

© 2026. Carved in code.

Click Reset. The stopwatch goes back to 0, starts ticking - and now it's counting by 2 every second. Click Reset again. Now it's counting by 3.

Nothing was deleted. Nothing was supposed to stack. But every reset adds another tick to the pile, and the pile never shrinks.

What the Return Statement Is For

useEffect(() => {
  const id = setInterval(() => {
    setSeconds((s) => s + 1);
  }, 1000);
 
  return () => clearInterval(id);
}, [running]);

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.

Stacking Intervals

export default function Stopwatch() {
  const [seconds, setSeconds] = useState(0);
  const [running, setRunning] = useState(true);
 
  useEffect(() => {
    if (!running) return;
    const id = setInterval(() => {
      setSeconds((s) => s + 1);
    }, 1000);
  }, [running]);
 
  function handleReset() {
    setSeconds(0);
    setRunning(false);
    setTimeout(() => setRunning(true), 50);
  }
 
  return (
    <div>
      <p>Elapsed: {seconds}s</p>
      <button onClick={handleReset}>Reset</button>
    </div>
  );
}

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.

The Fix: Return the Teardown

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.

Stacking Listeners

Same mechanism, different API:

export default function KeyCounter() {
  const [keyCount, setKeyCount] = useState(0);
  const [clicks, setClicks] = useState(0);
 
  useEffect(() => {
    function handleKey() {
      setKeyCount((k) => k + 1);
    }
    window.addEventListener("keydown", handleKey);
  }, [clicks]);
 
  return (
    <div>
      <p>Keys pressed: {keyCount}</p>
      <p>Button clicks: {clicks}</p>
      <button onClick={() => setClicks((c) => c + 1)}>Re-render</button>
    </div>
  );
}

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.

The Other Cleanup Bug: A Function That Returns undefined

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) calls clearInterval(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.

useEffect(() => {
  const id = setInterval(() => {
    setCount((c) => c + 1);
  }, 1000);
  return () => clearInterval(id);
}, []);

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.

Practice These Patterns

"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.