ReactBeginner

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.

DevFieldGuideJune 23, 2026 (updated July 5, 2026)6 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.

Component mountsEffect runs, sets up subscription
Dependencies changeCleanup runs, then effect re-runs
Component unmountsCleanup runs one final time

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.

Getting cleanup right in a Client Component matters most in the parts of an app that genuinely need one — everything else is often better left as a Server Component that never has an effect (or a cleanup function) to manage in the first place.

Cleanup with async effects — the pattern that trips people up

useEffect's callback can't be async directly (React expects it to return either nothing or a cleanup function, not a Promise), which means the natural instinct to write async () => { ... } as the effect body silently breaks cleanup. The correct pattern defines the async logic separately and calls it from a synchronous effect body:

jsx
useEffect(() => {
  let cancelled = false;
 
  async function loadUser() {
    const data = await fetchUser(userId);
    if (!cancelled) {
      setUser(data);
    }
  }
 
  loadUser();
 
  return () => {
    cancelled = true;
  };
}, [userId]);

The cancelled flag is the cleanup mechanism here, not AbortController — it doesn't stop the fetch itself, but it stops a resolved-too-late response from calling setState on a value that's no longer current (or, worse, on an unmounted component). For a real network request, pairing this with AbortController (as in the search example above) is the more complete fix, since it cancels the actual in-flight request rather than just ignoring its eventual result.

Cleanup and useLayoutEffect

useLayoutEffect follows the identical cleanup contract as useEffect — same return-a-function pattern, same "runs before next effect and on unmount" timing — the only difference is when it fires relative to the browser painting (synchronously after DOM mutations, before paint, versus useEffect's asynchronous-after-paint timing). Any cleanup logic that measures or mutates the DOM directly (removing a manually-attached DOM listener, disconnecting a ResizeObserver) belongs in useLayoutEffect's cleanup for the same reason the setup belongs there — keeping the measure/mutate and its corresponding cleanup on the same painting timeline avoids a visible flash between the two.

Cleanup for WebSocket connections

WebSockets are one of the clearest real-world cases for cleanup, since an open connection genuinely persists past the render that created it and needs an explicit close:

jsx
useEffect(() => {
  const socket = new WebSocket(`wss://api.example.com/rooms/${roomId}`);
 
  socket.addEventListener("message", handleMessage);
 
  return () => {
    socket.removeEventListener("message", handleMessage);
    socket.close();
  };
}, [roomId]);

Both the event listener removal and the close() call matter here — closing the socket without removing the listener first can, depending on the WebSocket implementation, still let a final in-flight message reach a handler for a component that's already unmounting.

Common mistakes

Common mistakes
  • Leaving the dependency array off entirely. Without it, the effect (and its cleanup) runs after every single render, not just on mount/unmount or when specific values change — usually not what was intended, and easy to misdiagnose as "the cleanup is running too often" when actually the effect itself is re-running too often.
  • Referencing a value inside cleanup that isn't in the dependency array. The cleanup closes over the value from whichever render created it, so if that value can change, the cleanup can end up "undoing" against a value that's no longer current.
  • Writing cleanup logic for something that isn't actually a subscription. Not every effect needs symmetry — forcing a cleanup function onto an effect that just triggers a one-time side effect adds code without preventing any real bug.
  • Assuming Strict Mode's double-invoke in development means there's a bug. It's intentional — React deliberately mounts, cleans up, and re-mounts in development specifically to surface effects that aren't safely cleaned up. Seeing it fire twice in dev and once in production is expected behavior, not an error.
Advertisement

Frequently Asked Questions

Advertisement
DevFieldGuide
DevFieldGuide

Editorial Team

Practical tutorials and developer tools, written and maintained by the DevFieldGuide team.

Enjoyed this article?

Get the next one straight to your inbox, along with the best of what we publish each week.

Related Articles

More in React

View all