Introduction
Almost everyone uses useEffect in React for fetching data, timers, or adding event listeners. The part that causes the most silent bugs is the cleanup function. Skip it or write it incorrectly and you get memory leaks, duplicate handlers firing, or the infamous setState on unmounted component. This post shows, with concrete examples, how to write a correct cleanup every time.
What useEffect does and what cleanup means
useEffect is for side effects — work that isn’t directly part of rendering. Its shape:
useEffect(() => {
// effect: do the side effect
return () => {
// cleanup: tear down what you set up
};
}, deps);
cleanup is the returned function. React runs it in two moments:
- Before the effect re-runs when any
depschange - When the component unmounts
In Strict Mode (dev), effects are mounted, cleaned up, then mounted again to expose bugs, so correct cleanup matters even more.
When does cleanup run?
-
Before the next effect
If any dependency changes, React calls the previous cleanup first, then runs the new effect. -
On unmount
When the component leaves the DOM, React calls the last cleanup for that effect.
Order example:
useEffect(() => {
console.log("effect");
return () => console.log("cleanup");
}, [count]);
When count goes from 0 → 1, you’ll see: cleanup → effect.
Essential cleanup scenarios
DOM events
useEffect(() => {
const handleResize = () => console.log(window.innerWidth);
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
};
}, []);
Without removal, each re-run adds another listener and the handler fires multiple times.
Timers (setInterval / setTimeout)
useEffect(() => {
const id = setInterval(() => console.log("tick"), 1000);
return () => clearInterval(id);
}, []);
Forget clearInterval and the timer keeps running after unmount, burning CPU.
Subscriptions (socket/observable)
useEffect(() => {
const unsubscribe = socket.subscribe(data => {
console.log(data);
});
return () => unsubscribe();
}, []);
Every subscription needs an exit path; otherwise old handlers keep receiving messages.
Network requests with AbortController
useEffect(() => {
const controller = new AbortController();
fetch("/api/data", { signal: controller.signal })
.then(res => res.json())
.then(data => console.log(data))
.catch(err => {
if (err.name !== "AbortError") console.error(err);
});
return () => controller.abort();
}, []);
Aborting prevents stray responses from calling setState on an unmounted component.
Dependencies and hidden bugs
- Each time a
depchanges, cleanup from the previous run fires first; rely on stable refs/state for teardown. - If you create handlers inside the effect and omit them from
deps, you may remove the wrong reference later (stale closure). UseuseCallbackor include the dependency. - In Strict Mode dev, the sequence is effect → cleanup → effect for a single mount; make your effect idempotent and reversible with its cleanup.
Common mistakes to avoid
- Declaring
asyncdirectly on theuseEffectcallback and forgetting to abort requests - Adding event listeners without removing them or with a different reference on cleanup
- Relying on
setIntervalwithout clearing it - Ignoring the dependency array and working with stale data
- Assuming cleanup only runs on unmount
Quick checklist before merging
- Does every resource you open (listener, timer, subscription, request) have a teardown path?
- Does the dependency array intentionally include everything you use — or is it empty on purpose?
- In Strict Mode dev, will effect → cleanup → effect still behave correctly?
- Are handlers/callbacks stable so
removeEventListenercan actually remove them?
Wrap-up
The cleanup function is small, but it prevents big problems. Every time you write useEffect, ask: “What did I start that needs to be shut down?” Answering that keeps your React app free of leaks, duplicate listeners, and setState errors.
