You upgrade to React 18, open the dev tools, and every useEffect fires twice. The fetch logs duplicate. The interval counter jumps in twos. The WebSocket opens, closes, reopens. None of that is a bug in React; it is Strict Mode telling you that your effects do not have cleanup, and the production runtime will eventually punish you for it: memory leaks, duplicate event handlers, stale data from cancelled requests that still resolved. The React 18 Strict Mode issue on GitHub and recurring r/reactjs threads end on the same conclusion in hundreds of comments. Strict Mode is the messenger. The missing cleanup is the bug.
This guide covers the seven cleanup patterns that come up in every production codebase, the most common mistakes with before/after comparisons, stale closure traps, and what changed in React 19.
By Ryan Calloway. Updated May 2026.
Quick answer: the function you return from useEffect runs right before the effect re-executes and when the component unmounts. Use it to cancel subscriptions, clear timers, remove listeners, and abort fetch requests. Anything allocated in the effect that outlives a single render needs a matching cleanup.
The shape you must memorize
useEffect(() => {
// setup: runs after render (when deps changed)
const id = setInterval(tick, 1000);
return () => {
// cleanup: runs before next setup, and on unmount
clearInterval(id);
};
}, [tick]);
Three timings: setup runs after every render where a dependency changed. Cleanup runs before each subsequent setup (not after the first run), and once on unmount. In development with Strict Mode on, React also runs a mount-cleanup-remount cycle to surface missing cleanups. Production runs the cycle once.
Pattern 1: timers
Every timer needs a clear. Without it, changing a dependency creates a second timer while the first keeps firing.
Wrong:
// Each time refreshFeed changes, a new interval stacks on top of the old one
useEffect(() => {
setInterval(() => refreshFeed(), 30_000);
}, [refreshFeed]);
Right:
useEffect(() => {
const id = setInterval(() => refreshFeed(), 30_000);
return () => clearInterval(id);
}, [refreshFeed]);
The canonical Stack Overflow thread on “state not updating when using React state hook within setInterval” converges on the same fix in dozens of variants: clear the interval in cleanup, and use a functional updater when reading state inside the callback.
// Using a functional updater to always read latest state
useEffect(() => {
const id = setInterval(() => {
setCount(prev => prev + 1); // reads latest, not the stale closure value
}, 1000);
return () => clearInterval(id);
}, []); // empty deps: no stale closure risk with functional updater
Pattern 2: event listeners
Wrong:
useEffect(() => {
// Inline arrow - cannot be removed because removeEventListener needs the same reference
window.addEventListener("resize", () => setWidth(window.innerWidth));
return () => window.removeEventListener("resize", () => setWidth(window.innerWidth)); // different function
}, []);
Right:
useEffect(() => {
const onResize = () => setWidth(window.innerWidth);
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize); // same reference
}, []);
The listener reference passed to removeEventListener must be the same function passed to addEventListener. Declare it inside the effect so each effect run gets a fresh reference that matches exactly what it registered.
Pattern 3: fetch and AbortController
If the component unmounts while a request is in flight, the setState in the .then fires on an unmounted component and logs a warning in React 18. AbortController is the fix.
Wrong:
useEffect(() => {
fetch(`/users/${id}`)
.then(r => r.json())
.then(setUser); // fires on unmounted component if id changes quickly
}, [id]);
Right:
useEffect(() => {
const ctrl = new AbortController();
fetch(`/users/${id}`, { signal: ctrl.signal })
.then(r => r.json())
.then(setUser)
.catch(err => {
if (err.name !== "AbortError") throw err; // swallow expected aborts
});
return () => ctrl.abort();
}, [id]);
Two things people miss. First, every fetch must receive the signal; passing it is not automatic. Second, the .catch must swallow AbortError; otherwise your error boundary fills with expected aborts. The MDN AbortController page covers the full lifecycle.
Pattern 4: WebSocket and EventSource
Open the connection in the effect, close it in the cleanup. Skip the cleanup and users accumulate one zombie WebSocket per remount.
useEffect(() => {
const ws = new WebSocket("wss://api.example.com/live");
ws.onopen = () => setStatus("connected");
ws.onmessage = (e) => setData(JSON.parse(e.data));
ws.onerror = () => setStatus("error");
ws.onclose = () => setStatus("disconnected");
return () => {
ws.close();
};
}, []); // stable connection; no deps unless URL is dynamic
For dynamic URLs where the connection should reset when the URL changes:
useEffect(() => {
const ws = new WebSocket(wsUrl);
ws.onmessage = (e) => setMessages(prev => [...prev, JSON.parse(e.data)]);
return () => ws.close();
}, [wsUrl]); // reconnects whenever wsUrl changes
Pattern 5: subscriptions (store, EventSource, observables)
useEffect(() => {
const sub = store.subscribe(newState => setData(newState));
return () => sub.unsubscribe();
}, []);
// EventSource (server-sent events)
useEffect(() => {
const es = new EventSource("/api/events");
es.onmessage = (e) => setEvents(prev => [...prev, JSON.parse(e.data)]);
return () => es.close();
}, []);
// RxJS observable
useEffect(() => {
const subscription = observable$.subscribe(value => setValue(value));
return () => subscription.unsubscribe();
}, [observable$]);
Pattern 6: observers (ResizeObserver, IntersectionObserver, MutationObserver)
All three observer APIs follow the same pattern: connect in setup, call disconnect() in cleanup.
// ResizeObserver - track element dimensions
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const observer = new ResizeObserver(entries => {
const { width, height } = entries[0].contentRect;
setDimensions({ width, height });
});
observer.observe(el);
return () => observer.disconnect();
}, []);
// IntersectionObserver - lazy load / infinite scroll trigger
useEffect(() => {
const el = sentinelRef.current;
if (!el) return;
const observer = new IntersectionObserver(
entries => {
if (entries[0].isIntersecting) fetchNextPage();
},
{ rootMargin: "200px" }
);
observer.observe(el);
return () => observer.disconnect();
}, [fetchNextPage]);
Pattern 7: custom hook extraction
When the same setup/cleanup pattern appears in more than one component, extract it into a custom hook. The cleanup semantics are identical – you return the cleanup function from useEffect inside the hook.
// Extract WebSocket logic into a reusable hook
function useWebSocket(url) {
const [data, setData] = useState(null);
const [status, setStatus] = useState("connecting");
useEffect(() => {
const ws = new WebSocket(url);
ws.onopen = () => setStatus("connected");
ws.onmessage = (e) => setData(JSON.parse(e.data));
ws.onerror = () => setStatus("error");
return () => {
ws.close();
setStatus("disconnected");
};
}, [url]);
return { data, status };
}
// Consume anywhere without repeating the cleanup logic
function PriceWidget({ symbol }) {
const { data, status } = useWebSocket(`wss://prices.example.com/${symbol}`);
if (status !== "connected") return <span>{status}</span>;
return <span>{data?.price}</span>;
}
The stale closure trap
The most common non-obvious bug: a callback inside useEffect captures an old value because the dependency array is incomplete or empty.
Wrong (stale closure):
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
console.log(count); // always logs 0 - count is captured at setup time
setCount(count + 1); // always sets to 1
}, 1000);
return () => clearInterval(id);
}, []); // missing `count` dep; ESLint will warn about this
}
Right (functional updater):
useEffect(() => {
const id = setInterval(() => {
setCount(prev => prev + 1); // always reads latest state, no stale closure
}, 1000);
return () => clearInterval(id);
}, []);
Right (complete deps):
// When you genuinely need to read the value (not just increment it):
useEffect(() => {
const id = setInterval(() => {
if (count >= 10) clearInterval(id); // reads latest count
setCount(count + 1);
}, 1000);
return () => clearInterval(id);
}, [count]); // effect re-runs each time count changes; interval resets
The ESLint rule exhaustive-deps (from eslint-plugin-react-hooks) catches most stale closure bugs at write time. Keep it enabled.
Race conditions with fetch
Even with AbortController, rapid dependency changes can cause out-of-order responses. An older slow request finishes after a newer fast one and overwrites the correct state.
useEffect(() => {
let cancelled = false; // local flag guards against out-of-order responses
const ctrl = new AbortController();
fetch(`/search?q=${query}`, { signal: ctrl.signal })
.then(r => r.json())
.then(results => {
if (!cancelled) setResults(results); // discard if a newer effect already ran
})
.catch(err => {
if (err.name !== "AbortError") setError(err);
});
return () => {
cancelled = true;
ctrl.abort();
};
}, [query]);
The cancelled flag is a defensive layer on top of AbortController. abort() stops the network request; the flag discards any response that arrived before the abort could take effect.
Strict Mode and the double-invocation
React 18 and 19 run useEffect twice in development when <StrictMode> is on. The goal is to surface missing cleanups: if your effect is idempotent and has cleanup, running it twice produces the same visible state.
// What you see in dev with StrictMode on - this is correct behavior:
// 1. effect runs: open WebSocket, log "connecting"
// 2. cleanup runs: close WebSocket, log "disconnected"
// 3. effect runs again: open WebSocket, log "connecting"
// In production: steps 1 and 3 only happen once combined
If your effect has no cleanup, you get two WebSockets open. The warning is the point. Do not disable Strict Mode to silence it; add the cleanup, and the double-invocation becomes a confirmation that your lifecycle is correct.
React 19: useEffectEvent
React 19 introduced useEffectEvent (stable API, previously an experimental RFC) to solve a specific stale closure pattern: when you need a callback inside useEffect that always reads the latest props or state but should not re-trigger the effect when those values change.
import { useEffect, useEffectEvent } from "react";
function ChatRoom({ roomId, onMessage }) {
// onMessage reads latest props but is not a dep - effect does not re-run when it changes
const handleMessage = useEffectEvent((msg) => {
onMessage(msg); // always uses the latest onMessage callback
});
useEffect(() => {
const ws = new WebSocket(`wss://chat.example.com/${roomId}`);
ws.onmessage = (e) => handleMessage(JSON.parse(e.data));
return () => ws.close();
}, [roomId]); // only reconnects when roomId changes, not when onMessage changes
}
useEffectEvent wraps a callback so it is always current but is excluded from the dependency array. Use it when the alternative is either adding a frequently-changing callback to deps (causing unnecessary reconnects) or removing it from deps (causing stale closure bugs).
The one case where you do not need a cleanup
Effects that only read or synchronously update a value with no subscription, timer, or async work:
useEffect(() => {
document.title = `Inbox (${unreadCount})`;
}, [unreadCount]);
No timer, no listener, no subscription, no async work. Setting the title is idempotent. The test: does this effect allocate anything that outlives the effect body? If no, no cleanup needed.
Effects that should not be effects
Some code people put in useEffect does not belong there:
- Derived state.
setBar(foo + 1)inside an effect should be a plain expression in render, oruseMemo. - Handling user actions. Setting state after a button click belongs in the event handler, not in an effect that watches state change.
- Initializing external state on mount. If the initialization should happen exactly once and does not need cleanup, consider module-level initialization outside the component instead.
The React team’s You Might Not Need an Effect page is the definitive list. The average codebase has three or four effects that should not have been effects.
Cleanup checklist
- Created an interval or timeout? Return a
clear*call. - Added an event listener? Return
removeEventListenerwith the same function reference. - Started a subscription? Return the
unsubscribe(). - Opened a connection (WebSocket, EventSource)? Return the
close(). - Fired a fetch? Return an
AbortController.abort()(and acancelledflag for race conditions). - Attached a ResizeObserver, IntersectionObserver, or MutationObserver? Return
observer.disconnect(). - Read state inside a callback? Either use a functional updater, add it to deps, or use
useEffectEventin React 19.
FAQ
When does the cleanup function run?
Before the next setup (when a dependency changes), once on unmount, and in development with Strict Mode, once after the first run as a verification pass. Production runs it only on unmount or when deps change.
Why does my cleanup run twice on mount?
Strict Mode. React intentionally mounts, cleans up, and remounts in dev to expose missing cleanups. If the sequence looks balanced (open / close / open), your code is correct. Production runs the cycle once.
What is the difference between useEffect and useLayoutEffect?
useEffect runs asynchronously after the browser paints. useLayoutEffect runs synchronously after React mutates the DOM but before paint. Use useLayoutEffect only for measurements that must apply before the user sees the frame. Cleanup semantics are identical.
Can I make the cleanup function async?
No. React ignores the return value if it is a Promise. If you need to await inside cleanup, start the async work and fire-and-forget: return () => { closeDriver().catch(console.error); }.
How do I debug a missing cleanup?
Turn on Strict Mode. Add console.log at both setup and cleanup. Trigger a re-render. If the setup log appears twice in a row without a cleanup log between them, you are missing the cleanup.
What changed in React 19 for effects?
useEffectEvent graduated from experimental to stable. Cleanup timing and semantics are unchanged. Strict Mode double-invocation still applies in development. Concurrent features (Suspense, transitions) interact with effects the same way they did in React 18.
Next steps
If cleanup bugs manifest as “state is not updating”, the useState not updating guide walks through the adjacent causes. For a deeper look at how the event loop interacts with effects (especially around setState timing), the JavaScript event loop guide is the next read.