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.