“When does the function returned from useEffect run?” Most candidates say “when the component unmounts”. That is a third of the answer, and the missing two thirds explain duplicate event listeners, chat rooms that stay connected after switching channels, and search results that flash in from an earlier query.
Interviewers ask about cleanup because it reveals whether you see an effect as a lifecycle callback or as synchronisation: something that starts when certain values hold and must be stopped when they no longer do.
Before you start
You should know the shape of useEffect(setup, dependencies) and what a dependency array does. Some familiarity with browser APIs that need explicit teardown helps: addEventListener and removeEventListener, setInterval and clearInterval, WebSocket.close, and AbortController. The behaviour below is the same in React 18 and 19. React 19 also lets ref callbacks return a cleanup function, which follows the same ownership idea.
The short answer
React runs the cleanup function before re-running the effect whenever a dependency changes, and once more when the component unmounts. In development under Strict Mode, React also runs an extra setup and cleanup immediately on mount to check that your cleanup truly undoes your setup. Each effect should own a resource: the setup acquires it and the cleanup, created in the same closure, releases exactly that resource. For async work, cleanup cannot cancel the promise, so it aborts the request or flips a flag that stops the stale result from setting state.
How it works
Every render that changes dependencies produces a new effect instance with its own closure. React commits the new UI, then calls the previous instance’s cleanup, then the new instance’s setup:
useEffect(() => {
const connection = createConnection(roomId);
connection.connect();
console.log('connected to', roomId);
return () => {
connection.disconnect();
console.log('disconnected from', roomId);
};
}, [roomId]);Switching from room general to room travel logs:
disconnected from general
connected to travelThe cleanup reads roomId and connection from its own closure, so it disconnects the old room even though the component now renders travel. That is ownership: the cleanup does not look up “the current connection”, it releases the one its setup created.
On unmount, only the last cleanup runs. In development with Strict Mode, mount looks like connected to general, disconnected from general, connected to general. If your code still works after that sequence, it will survive real dependency changes too.
Step-by-step walkthrough
Step 1: Pair every acquire with a release
Write the cleanup at the same time as the setup. Common pairs:
useEffect(() => {
function onResize() { setWidth(window.innerWidth); }
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
useEffect(() => {
const id = setInterval(() => setTick(t => t + 1), 1000);
return () => clearInterval(id);
}, []);removeEventListener needs the same function reference, which is why onResize is declared inside the effect rather than inline in both calls.
Step 2: Keep the resource in the closure, not in shared state
A frequent mistake stores the socket in a ref and closes “whatever is in the ref”. If two setups overlap (Strict Mode, or a fast dependency change), the second setup overwrites the ref and the first socket is never closed. A const inside the effect body cannot be overwritten by another run.
Step 3: Handle async results that arrive late
You cannot cancel an await, but you can make its result harmless:
useEffect(() => {
const controller = new AbortController();
fetch(`/api/search?q=${encodeURIComponent(query)}`, { signal: controller.signal })
.then(r => r.json())
.then(data => setResults(data))
.catch(err => {
if (err.name !== 'AbortError') setError(err);
});
return () => controller.abort();
}, [query]);When query changes, the old request is aborted, so its response can never overwrite newer results. The fetch race condition guide covers this pattern in depth, including the ignore flag for APIs that do not accept a signal.
Step 4: Decide what does not need cleanup
Not every effect acquires something. Setting document.title or sending a one-off analytics event has nothing to release, although a title change might reasonably restore the previous title. Ask “if this effect runs twice, is anything left behind?” If the answer is no, no cleanup is needed.
Worked scenario
A live price ticker subscribes to a WebSocket for the selected symbol:
const socketRef = useRef(null);
useEffect(() => {
socketRef.current = new WebSocket(`wss://prices.example/${symbol}`);
socketRef.current.onmessage = e => setPrice(JSON.parse(e.data).price);
}, [symbol]);
useEffect(() => () => socketRef.current?.close(), []);Symptoms: after switching symbols a few times, the price flickers between values for different symbols, and the browser shows several open sockets. Each symbol change opened a new socket and nothing closed the old one; the only cleanup ran on unmount and closed just the latest.
The fixed version lets one effect own one socket:
useEffect(() => {
const socket = new WebSocket(`wss://prices.example/${symbol}`);
socket.onmessage = e => setPrice(JSON.parse(e.data).price);
return () => socket.close();
}, [symbol]);Now every symbol change closes the previous socket before opening the next, and Strict Mode’s extra cycle opens and closes one socket harmlessly.
Common mistake
- “Cleanup only runs on unmount.” It runs before every re-run of the effect too.
- Disabling Strict Mode to stop the double connection. That hides the leak; production dependency changes will hit it anyway.
- Using a
didRunref to skip the second setup. It defeats the check and breaks Fast Refresh and future features that remount components, such as preserving state in hidden UI. - Returning a promise.
useEffect(async () => ...)returns a promise instead of a cleanup function, so React has nothing to call. Define the async function inside and call it. - An
isMountedflag shared across effect runs. It only covers unmount, not a dependency change, so stale responses still win the race.
Verify the behavior
Put logs in setup and cleanup and watch the order under Strict Mode:
import { StrictMode, useEffect, useState } from 'react';
function Room({ id }) {
useEffect(() => {
console.log('setup', id);
return () => console.log('cleanup', id);
}, [id]);
return <p>Room {id}</p>;
}
export default function App() {
const [id, setId] = useState(1);
return (
<StrictMode>
<button onClick={() => setId(i => i + 1)}>next</button>
<Room id={id} />
</StrictMode>
);
}On load in development you see setup 1, cleanup 1, setup 1. One click logs cleanup 1, setup 2. For leaks, open the browser’s DevTools network panel filtered to WS (or the event listeners panel) and confirm the count stays at one after repeated changes.
Follow-up questions
Does cleanup run before the browser paints? For useEffect, cleanup and setup run after paint in most cases. For useLayoutEffect, both run synchronously before paint. See layout effects for measurement.
How do I subscribe to a store without hand-written cleanup? Prefer useSyncExternalStore, which takes a subscribe function returning an unsubscribe. See external store subscriptions.
Why does cleanup see old props? Because it was created by the previous render’s effect. That is exactly what you want for releasing the previous resource.
What is new in React 19 here? Ref callbacks can return a cleanup function, so DOM-level subscriptions (like a ResizeObserver on one element) can live on the ref instead of in an effect.
Interview exercise
This effect keeps calling the handler multiple times per keypress after the shortcut prop changes. Find the bug and fix it.
useEffect(() => {
window.addEventListener('keydown', e => {
if (e.key === shortcut) onTrigger();
});
}, [shortcut, onTrigger]);Answer and reasoning
There is no cleanup, so every time shortcut or onTrigger changes a new listener is added and the old ones stay, each with its own captured shortcut. Also the listener is an anonymous inline function, so it could not be removed even if you tried. The fix names the handler inside the effect and returns a cleanup that removes the same reference:
useEffect(() => {
function onKey(e) { if (e.key === shortcut) onTrigger(); }
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [shortcut, onTrigger]);If onTrigger is a new function on every parent render, the listener is re-attached every render; that is correct but wasteful, and the callback dependencies guide covers stabilising it.
Continue learning
Practise with the React interview questions and the React MCQs. Related notes: effect dependencies, fetch race conditions with AbortController and Strict Mode double invoke. Official reference: Synchronizing with Effects and the useEffect reference on react.dev.