useEffect Deep — Cleanup, StrictMode Double-Invoke, Dep-Array Bugs, Effect Events
useEffect is the most-misused hook. The model the docs push is: effects synchronize external systems with React state — not “run after render,” not “componentDidMount equivalent.” Specific senior pain points: cleanup that actually undoes the setup, StrictMode double-invoke surfacing missing cleanups in dev, dep-array bugs (missing deps, stale closures, over-firing), and effect events (the React 19+ escape hatch for non-reactive values). Most “I need a useEffect” intuitions are actually derived state, event handlers, or subscriptions to external stores in disguise.
What does useEffect actually do?
Runs the effect function after the component renders and the DOM is committed. The returned function (cleanup) runs before the next effect fires and on unmount.
useEffect(() => {
const id = setInterval(tick, 1000);
return () => clearInterval(id);
}, []);Lifecycle:
- First render → effect runs → cleanup stored.
- Subsequent render with changed deps → stored cleanup runs → effect runs again → new cleanup stored.
- Unmount → stored cleanup runs.
Critically: the effect runs after paint (useEffect) or synchronously after DOM updates but before paint (useLayoutEffect). Use useLayoutEffect only when you need to measure or mutate DOM before the user sees anything.
Cleanup that actually undoes the setup.
Every external thing you start must be stopped. Common pairs:
useEffect(() => {
const handler = (e: KeyboardEvent) => { ... };
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [...]);
useEffect(() => {
const controller = new AbortController();
fetch(url, { signal: controller.signal }).then(setData);
return () => controller.abort();
}, [url]);
useEffect(() => {
const sub = source.subscribe(setValue);
return () => sub.unsubscribe();
}, [source]);
useEffect(() => {
const id = setTimeout(callback, 1000);
return () => clearTimeout(id);
}, []);The cleanup should be the opposite of the setup. Missing cleanup is the #1 source of memory leaks and “why is my old handler still firing?” bugs.
What is StrictMode double-invoke and why does it exist?
In dev with <React.StrictMode>, React runs each effect twice on mount (setup → cleanup → setup) intentionally. The point: surface bugs where your effect can’t be safely re-run.
useEffect(() => {
console.log("setup");
return () => console.log("cleanup");
}, []);
// In StrictMode dev output:
// setup
// cleanup
// setupIf your effect can’t survive being re-run (e.g., it leaks a subscription because you forgot to unsubscribe in cleanup), StrictMode catches it loudly in dev. Production doesn’t double-invoke.
Don’t disable StrictMode to “fix” double-invoke. Fix the missing cleanup.
The pattern this catches:
- Missing cleanup → double subscriptions, double fetch.
- Non-idempotent setup → 2x effect of side effects.
- Reading mutable state across renders → first effect captures pre-cleanup state.
The dep array — what are the rules?
Every value from the component scope used inside the effect must be in the deps. ESLint’s react-hooks/exhaustive-deps enforces it.
function Search({ query }: { query: string }) {
const [results, setResults] = useState([]);
// Wrong — uses query but doesn't list it
useEffect(() => {
fetch(`/api/search?q=${query}`).then(setResults);
// missing query → stale closure
}, []);
// Right — lists query
useEffect(() => {
fetch(`/api/search?q=${query}`).then(setResults);
}, [query]);
}Three failure modes:
- Missing deps → stale closure (effect uses an old value).
- Unstable deps (new object/function every render) → effect re-fires every render.
- Lying to ESLint (
// eslint-disable-next-line) → silently the same bugs, harder to find.
What’s a stale closure?
A function (or effect callback) that captures a value from an older render. The effect “thinks” it has the current value but actually has the value from when it was registered.
function Timer() {
const [count, setCount] = useState(0);
useEffect(() => {
setInterval(() => {
setCount(count + 1); // count is from render where effect was set up — always 0
}, 1000);
}, []); // empty deps → effect runs once → callback captures count=0 forever
}
// Fix 1 — functional setState (doesn't read closed-over count)
useEffect(() => {
setInterval(() => setCount(c => c + 1), 1000);
}, []);
// Fix 2 — include count in deps (re-creates interval each render — wasteful)
useEffect(() => {
const id = setInterval(() => setCount(count + 1), 1000);
return () => clearInterval(id);
}, [count]);Stale closures are pervasive in long-lived subscriptions (intervals, event listeners, WebSocket messages). Either use functional setState, or effect events (below).
Effect events — what are they?
useEffectEvent (stable since React 19.2, October 2025) — a function bound to an effect that always reads the latest values without being a dep.
import { useEffectEvent } from "react";
function Chat({ roomId, theme }: { roomId: string; theme: string }) {
const onConnected = useEffectEvent((connectionInfo) => {
showNotification(`Connected to ${roomId} using ${theme}`); // reads latest roomId + theme
});
useEffect(() => {
const conn = connect(roomId);
conn.on("connected", (info) => onConnected(info));
return () => conn.disconnect();
}, [roomId]); // only roomId — theme isn't a dep but is still read fresh
}The effect reconnects only when roomId changes (not on theme change). The callback always reads the latest theme. Solves the most common “I want fresh values without re-firing the effect” pain.
Before React 19.2 production code used workarounds (refs holding the latest values, or selectors from external stores); on 19.2+ use the hook.
useEffect vs useLayoutEffect — when each?
useEffect |
useLayoutEffect |
|
|---|---|---|
| When | After paint | Synchronously after DOM mutation, before paint |
| Use for | Most things — fetching, subscriptions, side effects | DOM measurement, DOM mutation that affects layout |
| Cost | Doesn’t block paint | Blocks paint until done |
Default to useEffect. Reach for useLayoutEffect only when you need to read/write DOM before the user sees the intermediate state — like measuring an element to position a tooltip relative to it.
useLayoutEffect warns in SSR (it doesn’t run on the server). The standard workaround is the useIsomorphicLayoutEffect pattern:
import { useEffect, useLayoutEffect } from "react";
export const useIsomorphicLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect;“I think I need a useEffect” — common cases where you don’t.
The React docs have a famous “you might not need an effect” guide. The standard misuses:
| Wrong with useEffect | Right answer |
|---|---|
| Derive state from props | Compute during render (no effect) |
| Reset state when props change | Use the key prop on the component |
| Update state in response to events | Update directly in the event handler |
| Cache expensive computation | useMemo |
| Fetch data | A library (TanStack Query, SWR) or RSC — see React Server Components |
| Update parent state with derived child state | Lift state up |
| Subscribe to external store | useSyncExternalStore |
| Run code on mount (one-time init) | Module-level constants or instance state |
The senior pattern: effects are for synchronizing with an external system. If the “external system” is “another React state,” the effect is probably the wrong tool.
How do you handle async work safely in an effect?
useEffect doesn’t accept an async function directly (the returned promise isn’t a cleanup). Wrap:
useEffect(() => {
let cancelled = false;
(async () => {
const data = await fetchData();
// guard against late updates after unmount
if (!cancelled) setData(data);
})();
return () => { cancelled = true; };
}, [url]);Better with AbortController:
useEffect(() => {
const controller = new AbortController();
fetch(url, { signal: controller.signal })
.then(r => r.json())
.then(setData)
.catch((e) => { if (e.name !== "AbortError") throw e; });
return () => controller.abort();
}, [url]);See Race conditions and AbortController for the full pattern.
Gotchas
- Function as a dep — functions defined in the component body change reference each render. Wrap in
useCallbackor move outside the component. - Object literal as a dep —
{a, b}is a new ref each render. Spread the props into individual primitive deps, oruseMemothe object. - Async setState after unmount — old code warned in React 17; React 18 silently no-ops (no warning) but it’s still wasted work. The
cancelledflag or AbortController prevents it. useEffect(() => x, [x])wherexis unchanged — still fires once on mount. Effects run at least once.useEffectwith no deps runs after every render — almost never what you want.- DevTools-time pause can re-fire effects when you resume — debug accordingly.
- Effect inside conditional is a hook-rules violation — hooks always run in the same order. Move the conditional inside the effect.
Related
Further reading
- React docs — “You Might Not Need an Effect”: https://react.dev/learn/you-might-not-need-an-effect
- React docs —
useEffect: https://react.dev/reference/react/useEffect - React docs —
useEffectEvent(RFC): https://react.dev/learn/separating-events-from-effects - Dan Abramov — “A Complete Guide to useEffect”: https://overreacted.io/a-complete-guide-to-useeffect/
Interview angle 5
- “What is
useEffectfor?” - synchronising with something outside React: a subscription, a browser API, a timer, an imperative library. If you are using one to derive state from props, push child state up to a parent, or run one-time initialisation, that is almost certainly the wrong tool. - “What goes in the dependency array?” - every value from the surrounding
scope that the effect reads.
react-hooks/exhaustive-depscomputes this correctly; lying to it produces a stale closure that reads values from an earlier render.- Follow-up: “What if you want the latest value without re-running the
effect?” - a functional
setStatewhere possible, a ref where you genuinely need to read something, or an effect event for the case the API was designed for. Removing the dep is not the answer.
- Follow-up: “What if you want the latest value without re-running the
effect?” - a functional
- “What belongs in the cleanup?” - the exact inverse of the setup:
unsubscribe,
removeEventListener,abort,clearTimeout. Anything you started, you stop.- Follow-up: “Why does React run effects twice in dev?” - StrictMode mounts, cleans up and mounts again specifically to surface a missing cleanup. Two requests in the network tab is the check working, not a bug to disable.
- “When do you use
useLayoutEffect?” - only when you must measure or mutate the DOM before the browser paints, such as positioning a tooltip against a measured rect. It blocks paint, souseEffectis the default anduseLayoutEffectneeds a reason. - “Should you fetch data in an effect?” - it works, and you will end up rebuilding caching, deduplication, retries and race handling yourself. Prefer a query library or a Server Component and keep effects for genuine external synchronisation.