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
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
// 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
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
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:
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:
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
- 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.
Related reading
- React Server Components Explained: What They Are and When to Use Them — shares tags: react, web-development, javascript (same category).
- The JavaScript Event Loop, Explained With Diagrams — shares tags: javascript, web-development.
- Next.js Middleware: What It's For and When to Avoid It — shares tags: react, web-development.
- Getting Started with the Next.js App Router — shares tags: react, web-development.
- Core Web Vitals Explained: What Actually Affects Your Score — shares tags: web-development.