React

React useEffect Cleanup: A Practical Guide

When and why to return a cleanup function from useEffect, with real examples of the bugs it prevents — subscriptions, timers, and stale event listeners.

Ava ChenJuly 10, 20262 min read
Share:

The cleanup function useEffect lets you return is easy to skip when your effect "seems to work fine" without it — until a component unmounts and remounts, and you find out why it was there.

The pattern

jsx
useEffect(() => {
  const subscription = api.subscribe(handleUpdate);
 
  return () => {
    subscription.unsubscribe();
  };
}, []);

The function you return runs in two situations: right before the effect re-runs (if dependencies changed), and when the component unmounts. Its job is to undo whatever the effect set up.

What happens if you skip it

jsx
// No cleanup — a bug waiting for the right conditions
useEffect(() => {
  window.addEventListener("resize", handleResize);
}, []);

If this component unmounts, the listener stays attached to window forever, holding a reference to a component instance that no longer exists. In React's Strict Mode (which intentionally mounts, unmounts, and remounts components in development to surface exactly this class of bug), you'd see handleResize firing twice per resize — a very common "why is this running twice" report that's actually cleanup, not a React bug.

Timers are the other classic case

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

Without clearInterval in the cleanup, every mount of this component leaves its own interval running in the background — mount it three times across navigation and you have three intervals all trying to update state on components that may no longer exist.

The stale closure trap

jsx
useEffect(() => {
  const controller = new AbortController();
 
  fetch(`/api/search?q=${query}`, { signal: controller.signal })
    .then((res) => res.json())
    .then(setResults);
 
  return () => controller.abort();
}, [query]);

This is the pattern for a search-as-you-type input. Without the AbortController cleanup, typing quickly fires multiple requests, and there's no guarantee the last request resolves last — an earlier, slower request can resolve after a newer one and overwrite the results with stale data. The cleanup cancels the in-flight request the moment a new one starts.

A simple rule of thumb

If your effect subscribes to something, starts something, or opens something — an event listener, a timer, a WebSocket, an external subscription, a fetch — ask "what undoes this?" and put that in the cleanup function. If the effect just reads a value or sets state once, it usually doesn't need one.

Advertisement
Ava Chen
Ava Chen

Senior Software Engineer

Ava writes about frontend architecture, React, and TypeScript. Previously built developer tools at scale-up startups.

Related Articles