By Ryan Calloway. Updated April 2026.
You upgrade to React 18, open the dev tools, and every useEffect in the app 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 in development telling you that your effects do not have cleanup, and the production runtime will eventually punish you for it (memory leaks, duplicate event handlers, “set state on unmounted component” warnings). The React 18 Strict Mode issue on GitHub and recurring r/reactjs threads like “React 18 Strict Mode running useEffect twice” end on the same conclusion in hundreds of comments: Strict Mode is the messenger, the missing cleanup is the bug. This guide is the four cases where cleanup is mandatory, the one case where skipping it is fine, and the Strict Mode trap.
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 rest explains why and how.
The shape you must memorize
useEffect(() => {
// setup: runs after render
const id = setInterval(tick, 1000);
return () => {
// cleanup: runs before next setup, and on unmount
clearInterval(id);
};
}, [tick]);
Three timings matter. The setup runs after every render where a dependency changed. The cleanup runs before each subsequent setup (not after the first one), and also runs once on unmount. Everything else follows from these three timings.
Case 1: timers (setInterval and setTimeout)
Every timer needs a clear. If the effect’s deps change, the old timer is still firing until you clear it.
useEffect(() => {
const id = setInterval(() => refreshFeed(), 30_000);
return () => clearInterval(id);
}, [refreshFeed]);
Without the cleanup, changing refreshFeed creates a second timer. After six re-renders, you have six timers racing. The canonical Stack Overflow thread on this is “state not updating when using React state hook within setInterval”; the answer in dozens of variants is the same: clear the interval in cleanup, and use a functional updater so you read the latest state.
Case 2: event listeners
useEffect(() => {
const onResize = () => setWidth(window.innerWidth);
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, []);
The listener reference passed to removeEventListener must be the same function passed to addEventListener. That is why you declare onResize inside the effect, not inline. An inline arrow in addEventListener is never removable.
Case 3: subscriptions (WebSocket, EventSource, store subscriptions)
useEffect(() => {
const sub = store.subscribe(newState => setData(newState));
return () => sub.unsubscribe();
}, []);
Same pattern for WebSocket and EventSource: open the connection in the effect, close it in the cleanup.
useEffect(() => {
const ws = new WebSocket("wss://api.example.com/live");
ws.onmessage = (e) => setData(JSON.parse(e.data));
return () => ws.close();
}, []);
Skip the cleanup and your users accumulate one zombie WebSocket per remount. The 2023 r/reactjs thread “Best way to use WebSockets in React” spent dozens of replies on this exact pattern; the recurring fix is “open in the effect, close in the return”.
Case 4: 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 (React 18) or crashes (earlier). AbortController is the fix.
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;
});
return () => ctrl.abort();
}, [id]);
Two things people miss. One, every fetch should accept the signal; passing it is not automatic. Two, the .catch has to swallow the AbortError; otherwise your error log fills with expected aborts. The MDN AbortController page has the full lifecycle.
The one case where you do not need a cleanup
Effects that only read or synchronously update DOM nodes with no subscription.
useEffect(() => {
document.title = `Inbox (${unreadCount})`;
}, [unreadCount]);
No timer, no listener, no subscription, no async work. Setting the title is idempotent and cheap. No cleanup needed.
If the rule is “does it allocate anything that outlives the effect body?” then this effect allocates nothing. That is the test.
Strict Mode and the double-invocation confusion
React 18 and 19 run useEffect twice in development when <StrictMode> is on. The goal is to surface missing cleanups: if your effect is correct, running it twice produces the same visible state.
// What you see in dev with StrictMode on:
// 1. effect runs: open WebSocket, log "connecting"
// 2. cleanup runs: close WebSocket, log "disconnecting"
// 3. effect runs again: open WebSocket, log "connecting"
If your effect has no cleanup, you get two WebSockets open in dev. The warning is the point. Do not disable Strict Mode to silence it; add the cleanup, and the duplication becomes visible as a correct sequence. Production does not double-invoke.
The official Strict Mode docs have a full worked example, and Dan Abramov’s “React as a UI Runtime” still reads as the best mental model for why the double-invocation behaviour exists.
A cleanup checklist
- Did you create an interval or timeout? Return a
clear*. - Did you add an event listener? Return a matching remover with the same function reference.
- Did you start a subscription? Return the unsubscribe.
- Did you open a connection (WebSocket, EventSource, observer)? Return the close.
- Did you fire a fetch? Return an
AbortController.abort(). - Did you attach a ResizeObserver, IntersectionObserver, MutationObserver? Return
observer.disconnect().
Six rules, one pattern. The recurring “memory leak in React” threads on r/reactjs and Stack Overflow almost always trace back to one of these six not cleaned up.
Effects that should not be effects
Some code people put in useEffect does not belong in one. Derived state (setBar(foo + 1)) should be a plain expression in render, or useMemo. Handling a user action (setX after a click) belongs in the event handler, not an effect that watches state.
The React team’s You Might Not Need an Effect page is the definitive list. The 2023 r/reactjs thread “You Might Not Need an Effect” ran the same audit on dozens of community examples; the consensus is that the average codebase has three or four effects that should not have been effects. Fewer effects mean fewer cleanups to think about.
FAQ
When does the cleanup function run?
Three times, in order: right 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, close), 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 for measurements that must apply before the user sees the frame. Cleanup semantics are the same.
Can I make the cleanup function async?
No. React ignores the return value of the cleanup if it is a Promise. If you need to await inside cleanup (closing a driver, flushing a buffer), start the async work inside the cleanup and fire-and-forget, or schedule it via Promise.resolve().then(async () => ...).
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.
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.