Frontend / React / 16_use_effect_deep.md

useEffect Deep — Cleanup, StrictMode Double-Invoke, Dep-Array Bugs, Effect Events

Updated 5 interview angles 8 min read source
On this page13
  1. What does useEffect actually do?
  2. Cleanup that actually undoes the setup.
  3. What is StrictMode double-invoke and why does it exist?
  4. The dep array — what are the rules?
  5. What’s a stale closure?
  6. Effect events — what are they?
  7. useEffect vs useLayoutEffect — when each?
  8. “I think I need a useEffect” — common cases where you don’t.
  9. How do you handle async work safely in an effect?
  10. Gotchas
  11. Related
  12. Further reading
  13. Interview angle

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.

tsx
useEffect(() => {
  const id = setInterval(tick, 1000);
  return () => clearInterval(id);
}, []);

Lifecycle:

  1. First render → effect runs → cleanup stored.
  2. Subsequent render with changed deps → stored cleanup runs → effect runs again → new cleanup stored.
  3. 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:

tsx
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.

tsx
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.

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.

tsx
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.

tsx
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.

tsx
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:

ts
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:

tsx
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:

tsx
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 useCallback or move outside the component.
  • Object literal as a dep — {a, b} is a new ref each render. Spread the props into individual primitive deps, or useMemo the 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 cancelled flag or AbortController prevents it.
  • useEffect(() => x, [x]) where x is unchanged — still fires once on mount. Effects run at least once.
  • useEffect with 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.

Further reading

Interview angle 5

  • “What is useEffect for?” - 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-deps computes 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 setState where 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.
  • “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, so useEffect is the default and useLayoutEffect needs 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.