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?
answer
- something started must be stopped
- the effect's return value
- one timer per mount, none removed
- clearInterval needs the id it returned
basics
~20 sThe 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 sThe 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
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.
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.
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.
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