Skip to content
·5 min read

The useEffect Cleanup Function Is Almost Always Missing Something

Most cleanup functions stop at clearing a timeout. The bugs that reach production come from the cases nobody covers: aborted fetches, stale closure writes, listeners on changing deps, and StrictMode's double invoke.

ReactHooksJavaScriptWeb Dev

Here is the version of useEffect that ships in most tutorials:

useEffect(() => {
  const id = setInterval(fetchNotifications, 30_000);
  return () => clearInterval(id);
}, []);

Clean. And in a real app, almost always missing something. The interval gets cleared while the fetch it triggered is still in flight, the response lands after the user navigated away, and a setState fires on a component that is gone. React logs a warning, the state corrupts something else, and nobody connects the two events.

Step 1: Know what actually needs cleanup

Rendering JSX needs no cleanup. Everything that reaches outside the component does:

  • Subscriptions: WebSocket, event bus, addEventListener, browser APIs
  • Timers: setInterval, setTimeout, animation frames
  • In-flight network requests
  • Writes into refs that outlive the effect
  • Manual DOM changes React does not own

The mental model I use: the effect borrowed something from the outside world. Cleanup returns it. If your effect did not borrow anything, it probably does not need cleanup. If it did and you skip the return, you wrote a leak with extra steps.

Step 2: Abort the fetches, not just the state updates

The classic race: a search box fires a request per keystroke, responses come back out of order, and the last one to arrive wins even when it is not the newest query. Cleanup is where you fix it:

useEffect(() => {
  const controller = new AbortController();
 
  fetch(`/api/search?q=${query}`, { signal: controller.signal })
    .then((r) => r.json())
    .then(setResults)
    .catch((e) => {
      if (e.name !== "AbortError") setError(e);
    });
 
  return () => controller.abort();
}, [query]);

When query changes or the component unmounts, the request is cancelled at the network level. No late setResults, no out-of-order overwrite, no wasted bandwidth. The .catch guard matters: an abort throws, and you do not want to render an error state because the user typed faster.

Step 3: Guard stale writes with a cancelled flag

Not every async operation accepts a signal. For everything else, a flag makes late completions harmless:

useEffect(() => {
  let cancelled = false;
 
  loadLocale(locale).then((dict) => {
    if (!cancelled) setDictionary(dict);
  });
 
  return () => {
    cancelled = true;
  };
}, [locale]);

The work still finishes in the background, but its result is dropped on the floor instead of written into state that moved on. Cheap, boring, and it eliminates an entire category of "sometimes the UI shows the wrong data" bug reports.

Step 4: Re-run cleanup on dependency changes, not just unmount

The most common misunderstanding I see in code review: cleanup is not an unmount hook. React runs it before every re-execution of the effect:

useEffect(() => {
  window.addEventListener("resize", onResize);
  return () => window.removeEventListener("resize", onResize);
}, [onResize]);

When onResize changes identity, the old listener is removed and the new one attached. Without the cleanup, every re-render of the parent stacks one more listener onto window, and by the time anyone notices, the handler fires five times per resize event. Subscriptions work the same way. Tear down the old value's subscription before subscribing to the new one.

Step 5: Expect StrictMode to run everything twice

In development, React deliberately mounts, unmounts, and remounts every component once. Your effect runs, cleans up, and runs again. That is not a bug, it is a test: effects with correct cleanup survive the cycle silently, effects without it fail loudly.

Double notifications in dev, listeners that fire twice, duplicate requests: these are StrictMode telling you the cleanup is incomplete. Fix the cleanup instead of removing StrictMode, and production gets the same correctness for free.

The habit that ties it together

Every time I write useEffect(() => {, I write the return () => line next, before the body. Then I ask one question: what did this effect borrow, and does the return give it back? Abort the requests, guard the writes, remove the listeners, clear the timers.

Effects are where React touches the world outside its model. Cleanup is the contract that touching the world twice in a row leaves no residue.

Chasing race conditions or duplicate-subscription bugs in a React codebase? This is a routine audit for me. Tell me about the project and I will show you where the effects are leaking.