skip to content

AbortController and Cleanup on Unmount

How to genuinely cancel in-flight work when a component unmounts or its dependencies change, using AbortController together with the effect's cleanup function. Expect follow-ups about the old 'cannot set state on an unmounted component' warning and why cancelling is about wasted work, not just warnings.

part ofReactoverview, primer and where to startread it →
on this pageshow

explore

questions

4

In React, a component polls by calling setInterval inside a useEffect. What must that effect return, and what goes wrong on a screen the user opens and leaves repeatedly?

level: juniorimportance: must knowfreq 62%

answer

  1. something started must be stopped
  2. the effect's return value
  3. one timer per mount, none removed
  4. clearInterval needs the id it returned

basics

~20 s

The effect must return a cleanup function that calls clearInterval with the id setInterval returned. Without it, every mount leaves another live timer behind, so abandoned components keep polling forever and traffic multiplies with each visit.

solid answer

~40 s

The effect has to return a cleanup function that calls `clearInterval` with the id `setInterval` gave it. React runs that function when the component unmounts, and again before the effect re-runs on a dependency change. Without it nothing stops the timer: React only tears down what you hand back to it, and a timer id lives in the host environment, not in the component. So every mount leaves another interval alive, all of them still firing and still holding their closures — after a user opens and closes the screen ten times, the server sees ten times the polling, and ten callbacks are calling state setters on components that no longer exist. The same discipline applies to `setTimeout`, `requestAnimationFrame`, event listeners, observers, sockets, and in-flight requests.

go deeper

for a junior

Know that an effect which starts something must return a function that stops it, and be able to write the setInterval/clearInterval pair from memory without hesitating over which value clearInterval takes.

for a middle

Be ready to explain the mechanics: React only calls the function the effect returns, the timer id lives in the host environment, and cleanup runs both at unmount and before the next run of the same effect.

for a senior

Show how you would catch this in production — traffic that grows with session length rather than with user activity, requests from screens nobody has open — and describe the review habit that prevents it in the first place.

for a principal

Frame the tradeoff at codebase scale: a lint rule and a shared usePolling-style abstraction that owns the teardown once beat asking every author to remember, because leaked timers are silent until they are a load problem.

## The code that causes it A polling component usually starts its timer in an effect: ```js useEffect(() => { const id = setInterval(() => { fetch('/api/status') .then((res) => res.json()) .then(setStatus); }, 5000); }, []); ``` This works on the first render and is broken the moment the component goes away. The effect starts a repeating timer and hands React nothing with which to stop it. ## Why React cannot stop it for you React manages the component tree; it does not manage the browser. `setInterval` registers a repeating callback with the host environment and returns an id that is the only handle to it. Nothing in that transaction mentions the component that made the call, so when React removes the component from the tree the timer is untouched — the environment has no idea the caller is gone. The one channel React gives you is the effect's return value. If the function passed to `useEffect` returns a function, React stores it and calls it before the next run of that same effect and once when the component unmounts. That returned function is the entire cleanup contract: ```js useEffect(() => { const id = setInterval(tick, 5000); return () => clearInterval(id); }, []); ``` Now exactly one interval is alive for exactly as long as the component is mounted. ## Why the closure matters The id must be captured in the same effect run that created the timer. `const id` lives in the effect body, and the cleanup function closes over it, so each cleanup stops precisely the timer its own run started. Two instances of the component mounted at once each get their own `id`, and neither can clear the other's. Hoisting the id to a module-level variable breaks that: the second mount would overwrite the first one's id, and one timer would survive every unmount. A ref works but buys nothing here. A common slip is passing the wrong value: `clearInterval(tick)` does nothing, because `clearInterval` takes the id, not the callback. ## Why it compounds This class of bug is invisible in a single session on a single screen. It shows up when the component mounts repeatedly — a route the user navigates in and out of, a modal, a tab panel, a list row that expands. Each mount adds an independent timer, so after N visits the app fires N requests every interval, and the count only ever grows until a full page reload. The abandoned callbacks also keep their closures reachable — the state setters, any captured props or cached payloads — so memory climbs alongside the traffic. The symptoms an interviewer likes to hear you name: request volume that grows with session length rather than with what the user is doing; the network tab showing calls belonging to a screen that is no longer open; and, in development, an accelerating pulse of requests as hot reloads remount the component again and again. ## Dependency changes count as teardown too If the effect has dependencies, the same guarantee protects you there: ```js useEffect(() => { const id = setInterval(tick, delayMs); return () => clearInterval(id); }, [delayMs]); ``` When `delayMs` changes, React clears the old timer before starting the new one, so the poll rate changes rather than doubling. An effect without cleanup would leave the old-rate timer running alongside the new one, which is how a component ends up polling at three different intervals at once. ## The whole family of the same rule Anything an effect starts that outlives the render must be stopped by the cleanup it returns, and each has its matching teardown call: `setInterval`/`clearInterval`, `setTimeout`/`clearTimeout`, `requestAnimationFrame`/`cancelAnimationFrame`, `addEventListener`/`removeEventListener` with the identical function reference, observers with `disconnect()`, sockets with `close()`, and an in-flight `fetch` cancelled through an `AbortController`. If you can write down the call that started something, you owe the call that stops it, in the returned function, in the same effect run. A good habit when reviewing an effect: read only its first and last lines. If the first line starts something and the last line is not a `return` that stops it, the effect leaks.

  • Two instances of that polling component are on screen at once. Why does each one's cleanup stop only its own timer?
    Because each effect run has its own `const id` and its own cleanup closure over that binding. React stores the cleanup per component instance and per effect run, so instance A's cleanup can only ever see the id A created. That breaks the moment you hoist the id to a module-level variable shared by both instances — then the second mount overwrites the first id and one timer outlives every unmount.
  • A colleague writes `return () => clearInterval(tick)` where tick is the callback. What happens?
    Nothing is cleared. `clearInterval` expects the id that `setInterval` returned; handed anything else it silently does nothing rather than throwing, so the timer keeps firing and the bug looks like missing cleanup even though a cleanup function exists. It is a good argument for keeping the id in a `const` named after the timer and passing that exact binding.
  • Does the polling effect still leak if the callback only reads a ref and never sets state?
    Yes. The leak is the running timer and the work it does — the request it fires, the CPU it wakes, the closure it retains — not the state update. Whether the callback writes state changes nothing about whether the interval is still scheduled after the component is gone.

saying these in an interview costs you the question

  • React automatically clears timers started inside an effect
  • Garbage collection stops the interval once the component unmounts
  • Passing the callback to clearInterval instead of the returned id
  • One stray timer is harmless; only fetches need cleaning up
  • Cleanup only matters for effects that have dependencies

context

open as a page

In React, how do you wire an AbortController into a useEffect that calls fetch, and why must the controller be created inside the effect body rather than held in a ref?

level: middleimportance: must knowfreq 68%

basics

~20 s

Create a fresh AbortController inside the effect body, pass controller.signal as fetch's signal option, and return a cleanup that calls controller.abort(). A controller is single-use: once aborted its signal stays aborted, so a shared one poisons every later request.

open as a page

A React details panel refetches in a useEffect keyed on selectedId and calls controller.abort() in the effect's cleanup. Clicking quickly through the list now flashes a 'Request failed' error banner. What is happening, and how do you fix it?

level: seniorimportance: should knowfreq 42%

basics

~20 s

Aborting rejects the in-flight fetch promise, and the effect's catch branch sets error state without checking why it rejected, so a deliberate cancellation renders as a failure. Guard the catch: when the rejection is an abort, return without writing any state.

open as a page

React 18 removed the 'Can't perform a React state update on an unmounted component' warning. Does that mean effects that fetch no longer need cancellation and cleanup?

level: seniorimportance: should knowfreq 34%

basics

~20 s

No. The warning was removed because it was wrong — an update on an unmounted component is a harmless no-op, not a leak. What genuinely leaks is anything still running: intervals, subscriptions, observers. And cancelling requests was always about wasted work, not about the console.

open as a page