Cleanup Functions in useEffect: Stop Leaks Before They Start
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 deps change
- 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 dep changes, 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). Use
useCallbackor include the dependency. - In Strict Mode dev, the sequence is
effect โ cleanup โ effectfor 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 โ effectstill 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.
Comments
No comments yet. Start the discussion.