React useEffect Cleanup: When and Why It Matters

When an effect sets up a timer, subscribes to a socket, or registers an event listener, forgetting to clean it up can cause duplicate work or leave resources active longer than intended. React calls an effect’s cleanup function before re-running the effect when its dependencies change, and when the component unmounts. In development, Strict Mode also runs an extra setup-and-cleanup cycle to help expose missing cleanup logic.

What the cleanup function actually does

useEffect can return a function. React calls that function in two situations: right before the effect runs again (if its dependencies changed), and when the component unmounts.

useEffect(() => {
  const id = setInterval(() => setTick(t => t + 1), 1000);

  return () => clearInterval(id);
}, []);

Without cleanup, an interval can keep firing after its component is removed or its effect re-runs. If the component mounts again without the previous interval having been cleared, another interval gets created alongside it, and now multiple timers are updating state or consuming resources. Proper cleanup ensures each effect instance stops the timer it created.

Note that React’s Strict Mode running an extra setup-cleanup-setup cycle on mount in development is not itself evidence of a leak — that’s deliberate, there to help surface exactly this kind of missing-cleanup bug. It only compounds into a real problem when cleanup is actually missing or broken.

The three bugs cleanup prevents

1. Leaked subscriptions. Any effect that subscribes to something external — a WebSocket, a window event listener, a third-party SDK — needs to unsubscribe in cleanup, or the subscription outlives the component.

useEffect(() => {
  function handleResize() { setWidth(window.innerWidth); }
  window.addEventListener("resize", handleResize);
  return () => window.removeEventListener("resize", handleResize);
}, []);

2. Stale results and race conditions. If id changes while a previous request for the old id is still in flight, that outdated response can resolve after the newer one and overwrite good data with stale data — a race condition. (This isn’t a logged warning in current React — React 18 removed the “setState on an unmounted component” warning, so there’s no console signal for this; it just silently produces the wrong UI.) Cancelling the request with AbortController handles the fetch case; for async work that can’t be cancelled, a cleanup-set flag that gates whether a result still gets applied is the more general fix.

function UserProfile({ id }) {
  const [user, setUser] = useState(null);
  const [error, setError] = useState(null);

  useEffect(() => {
    const controller = new AbortController();

    async function loadUser() {
      try {
        setError(null);
        const response = await fetch(`/api/user/${id}`, { signal: controller.signal });
        if (!response.ok) throw new Error(`Request failed: ${response.status}`);
        setUser(await response.json());
      } catch (err) {
        if (err.name === "AbortError") return;
        setError(err);
      }
    }

    loadUser();
    return () => controller.abort();
  }, [id]);

  if (error) return <p>Failed to load user.</p>;
  if (!user) return <p>Loading...</p>;
  return <h1>{user.name}</h1>;
}

One nuance: this keeps the previous user’s data on screen while the next request loads — reset user to null at the start of loadUser if that’s not the UI behavior wanted.

3. Duplicate effects on dependency changes. If an effect’s dependency array changes on every render (common with an inline object or array literal), the effect tears down and re-runs constantly. Cleanup isn’t the fix here — a stable dependency is — but understanding that React always cleans up before re-running makes the duplicate-call behavior much less mysterious when debugging it.

// Problem: `options` is a new object every render, so the effect re-runs every render
function ChatRoom({ roomId }) {
  const options = { roomId, serverUrl: "https://localhost:1234" };

  useEffect(() => {
    const connection = createConnection(options);
    connection.connect();
    return () => connection.disconnect();
  }, [options]);
}

// Fix: build the object inside the effect, depend on the primitive value instead
function ChatRoom({ roomId }) {
  useEffect(() => {
    const options = { roomId, serverUrl: "https://localhost:1234" };
    const connection = createConnection(options);
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]);
}

Cleanup correctly disconnects the stale connection each time in the first version — but that’s masking the real problem, not fixing it. The unstable dependency is still forcing an unnecessary reconnect on every single render.

A quick mental model

Think of the function passed to useEffect as “do this,” and its return value as “undo that.” If the “do” step touches anything outside the component — a timer, a listener, a subscription, an in-flight request — there’s almost always an “undo” step that belongs in cleanup. If the effect only sets local state from props that are already in scope, there’s usually nothing to clean up at all.

See also: 10 Online Code Playgrounds for React, Python & More for a React sandbox to test effect timing without a local project, and Server Components vs Client Components: A Practical Comparison for where useEffect fits — and doesn’t — once Server Components are in the mix.

Leave a Comment

Your email address will not be published. Required fields are marked *