useEffect Deep — Cleanup, StrictMode Double-Invoke, Dep-Array Bugs, Effect Events
TL;DR
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.
Interview Q&A
Q: What does useEffect actually do?
A: 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.
Q: Cleanup that actually undoes the setup.
A: 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.
Q: What is StrictMode double-invoke and why does it exist?
A: 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
// setup
If 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.
Q: The dep array — what are the rules?
A: 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.
Q: What’s a stale closure?
A: 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).
Q: Effect events — what are they?
A: experimental_useEffectEvent (React 19, stable name TBC) — a function bound to an effect that always reads the latest values without being a dep.
import { experimental_useEffectEvent as 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.
API is still experimental; production code uses workarounds (refs holding latest values, or selectors from external stores).
Q: useEffect vs useLayoutEffect — when each?
A:
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;
Q: “I think I need a useEffect” — common cases where you don’t.
A: 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 server_components.md |
| 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.
Q: How do you handle async work safely in an effect?
A: 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();
if (!cancelled) setData(data); // guard against late updates after unmount
})();
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 ../11_apis_data_fetching/05_abort_and_race_conditions.md for the full pattern.
Gotchas / edge cases
- 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.
What a senior is expected to say
- “Effects synchronize with external systems. If I’m using one to derive state, update parent state from child state, or run a one-time init, I’m probably misusing it.”
- “Every value from scope used inside the effect must be in the deps. Use
react-hooks/exhaustive-deps; don’t lie to ESLint.” - “Cleanup is the inverse of setup — subscribe/unsubscribe, addListener/removeListener, abort/clearTimeout. StrictMode double-invoke catches missing cleanups in dev.”
- “Stale closures from missing deps are the classic bug. Functional setState or
useEffectEvent(when it stabilizes) for cases where you want the latest value without re-firing the effect.” - “
useLayoutEffectonly when you need to measure or mutate DOM before paint; default touseEffect.” - “For data fetching, prefer TanStack Query or RSC.
useEffect+fetchworks but you’ll rebuild caching/dedup/retry yourself.”
Cross-references
- Stale closures and Rules of Hooks deeper: stale_closures_and_hook_rules.md
- AbortController in effects: ../11_apis_data_fetching/05_abort_and_race_conditions.md
- TanStack Query (the “don’t use effect for data” answer): ../11_apis_data_fetching/02_tanstack_query.md
- Server Components (the modern “don’t use effect for data on SSR” answer): server_components.md
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/