Ch. 3 · React

useEffect Pitfalls Interviewers Love to Ask

Stale closures, missing cleanup, fetch races, Strict Mode's double run, infinite loops and effects you don't need, plus how useLayoutEffect differs in timing.

~7 min readintermediate

Most useEffect questions aren’t really about the API, which is tiny. They’re about the mental model: an effect synchronizes your component with something outside React, it runs after React has committed a render, and it re-runs when the values it reads change. Nearly every classic effect bug (stale values, leaked timers, request races, infinite loops, flicker) comes from breaking one of those ideas. Here are the pitfalls that come up most, and the fix an interviewer expects for each.

When effects run

React renders your component, commits the result to the DOM, and only then runs your effects. When the browser paints relative to that depends on what triggered the update: for updates not caused by an interaction (like a click), React generally lets the browser paint before running your effect. The dependency array decides whether the effect runs again after a later render:

useEffect(() => {
  // after every commit
});

useEffect(() => {
  // after the first commit only
}, []);

useEffect(() => {
  const connection = createConnection(roomId);
  connection.connect();
  return () => connection.disconnect();
}, [roomId]); // after the first commit, then whenever roomId changes
TSX

React compares each dependency with its previous value using Object.is. When roomId changes from "general" to "travel", the order is: render with "travel", commit, run the previous effect’s cleanup (disconnecting from "general"), then run the new setup. The cleanup also runs once more on unmount.

Dependencies and stale closures

The dependency array is not a “when should this run” setting. It’s the list of every reactive value the effect reads: props, state, and anything computed from them during render. The react-hooks/exhaustive-deps lint rule checks it for you. Leave a value out and the effect keeps using the copy from the render it was created in. That’s a stale closure.

function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      setCount(count + 1); // 🐛 count is always 0 here
    }, 1000);
    return () => clearInterval(id);
  }, []); // count is read but not listed

  return <p>{count}</p>;
}
TSX

The interval callback closes over count from the first render, so every tick sets the count to 0 + 1 and the display gets stuck at 1. Adding count to the dependencies works but tears the interval down and recreates it every second. The better fix is to stop reading count at all by using an updater function: setCount((c) => c + 1). Now the empty array is honest.

Sometimes an effect needs the latest value of something without re-running when it changes. React 19.2 added useEffectEvent for exactly this:

function ChatRoom({ roomId, theme }: { roomId: string; theme: string }) {
  const onConnected = useEffectEvent(() => {
    showNotification("Connected!", theme); // always sees the latest theme
  });

  useEffect(() => {
    const connection = createConnection(roomId);
    connection.on("connected", () => onConnected());
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]); // changing the theme doesn't reconnect
}
TSX

Gotcha

Effect Events are only for logic that is genuinely an “event” fired from an effect. Don’t use useEffectEvent, or an // eslint-disable comment, to hide a dependency the effect really does react to.

Cleanup: timers, subscriptions, fetches

The rule is simple: whatever setup starts, cleanup stops. Timers need clearTimeout or clearInterval, subscriptions need an unsubscribe, and event listeners need removeEventListener with the same function reference.

useEffect(() => {
  function onResize() {
    setWidth(window.innerWidth);
  }
  window.addEventListener("resize", onResize);
  return () => window.removeEventListener("resize", onResize);
}, []);
TSX

Fetching is the one people forget. If the user types “re” and then “react”, two requests are in flight, and if the first one resolves last, the screen shows results for the wrong query. Cancel the stale request with an AbortController:

useEffect(() => {
  const controller = new AbortController();

  fetch(`/api/search?q=${encodeURIComponent(query)}`, { signal: controller.signal })
    .then((res) => res.json())
    .then((data) => setResults(data))
    .catch((err) => {
      if (err.name !== "AbortError") setError(err);
    });

  return () => controller.abort();
}, [query]);
TSX

Or, for promise-based APIs you can’t cancel, ignore any response that arrives after cleanup:

useEffect(() => {
  let ignore = false;
  fetchResults(query).then((data) => {
    if (!ignore) setResults(data);
  });
  return () => {
    ignore = true;
  };
}, [query]);
TSX

Aborting also saves bandwidth; the ignore flag works with anything. In production apps, a framework or data-fetching library usually handles caching and races for you, but you should be able to write this by hand.

Strict Mode runs it twice

In development, with <StrictMode>, React runs one extra setup and cleanup cycle before the first real setup. On mount you’ll see:

useEffect(() => {
  console.log("connect", roomId);
  return () => console.log("disconnect", roomId);
}, [roomId]);

// Development + Strict Mode, on mount:
// connect general → disconnect general → connect general
TSX

This is a stress test: if your cleanup truly mirrors your setup, the user can’t tell the difference between setup and setup → cleanup → setup. If they can (two open connections, a duplicate subscription, an animation that doesn’t stop), the bug is a missing or incomplete cleanup, and it would also show up in production when the component unmounts and remounts. Production builds run the effect once.

Interview tip

“Why does my effect run twice?” is a favorite question. The strong answer: it’s Strict Mode in development, it’s intentional, and the fix is a correct cleanup, not a useRef flag that skips the second run.

Strict Mode also calls your component functions twice in development, along with the functions you pass to useState, useMemo, useReducer and state setters, to surface impure rendering.

Infinite loops from unstable deps

The simplest loop is setting state in an effect with no dependency array: the effect runs after every render, the state update causes a render, and so on. The sneakier one is an object or function dependency created during render:

function SearchResults({ query }: { query: string }) {
  const [results, setResults] = useState<Result[]>([]);
  const options = { query, limit: 10 }; // a new object every render

  useEffect(() => {
    search(options).then(setResults); // new array → re-render → new options
  }, [options]); // 🐛 never Object.is-equal to last render's object

  return <ResultList items={results} />;
}
TSX

Two objects with the same contents are still different references, so the effect runs after every render, and because it sets state to a fresh array, there’s always another render. Move the object inside the effect and depend on the primitives it’s built from:

useEffect(() => {
  const options = { query, limit: 10 };
  search(options).then(setResults);
}, [query]);
TSX

The same goes for functions: define them inside the effect, move them outside the component if they don’t use props or state, or, as a last resort, stabilize them with useCallback. The trade-offs are covered in memo, useMemo and useCallback: When They Actually Help.

You might not need an effect

Effects are for synchronizing with things outside React: the DOM, the network, timers, third-party widgets. If you’re only transforming props or state, compute it during render instead.

// 🔴 Extra state, extra render, and a frame with stale data
const [visibleTodos, setVisibleTodos] = useState<Todo[]>([]);
useEffect(() => {
  setVisibleTodos(todos.filter((t) => !t.done));
}, [todos]);

// ✅ Just calculate it (wrap in useMemo only if it's measurably slow)
const visibleTodos = todos.filter((t) => !t.done);
TSX

The effect version renders with the old list, commits, runs the effect, sets state and renders again. A few more cases from the same family:

  • Resetting state when a prop changes: give the component a key, e.g. <ProfileForm key={userId} />, instead of an effect that clears state. See React Reconciliation & Keys.
  • Responding to a user action: put the logic in the event handler. By the time an effect runs, you no longer know which button was clicked.
  • Chains of effects that each set state to trigger the next: compute what you can during render and update the rest together in the event handler.

useLayoutEffect vs useEffect

useLayoutEffect has the same signature but runs after React updates the DOM and before the browser repaints. Its code, and any state updates it schedules, block the paint. That’s what you want when you need to measure layout and adjust before the user sees anything, such as positioning a tooltip:

function Tooltip({ top, children }: { top: number; children: ReactNode }) {
  const ref = useRef<HTMLDivElement>(null);
  const [height, setHeight] = useState(0);

  useLayoutEffect(() => {
    setHeight(ref.current!.getBoundingClientRect().height);
  }, []);

  // Render above the target once the height is known
  return <div ref={ref} style={{ top: top - height }}>{children}</div>;
}
TSX

With useEffect, the browser could paint the tooltip in the wrong place for a frame and then jump.

useEffect useLayoutEffect
Runs After commit, usually after paint After commit, before paint
Blocks painting Generally no Yes
Typical use Subscriptions, fetching, timers Measuring layout, avoiding flicker

Default to useEffect; reach for useLayoutEffect only when you see a visual glitch. Neither runs during server rendering.

The interview answer

“An effect synchronizes a component with an external system, and it runs after React commits a render. The dependency array isn’t a scheduling knob; it lists every reactive value the effect reads, and React compares them with Object.is. Most bugs come from breaking that: omitting a value gives you a stale closure, while depending on an object or function created during render re-runs the effect every time and can loop.

Every setup should have a cleanup that undoes it, whether that’s clearing a timer, unsubscribing, or aborting a fetch so a slow response can’t overwrite a newer one. Strict Mode’s extra setup+cleanup in development exists to prove that. And before writing an effect at all, I check whether the value can simply be computed during render or handled in an event handler. I use useLayoutEffect only when I need to measure the DOM before paint.”

More in React

read ✓React · mid

React Reconciliation & Keys: What Really Happens

Render vs commit, the diffing rules React relies on, why index keys break inputs, how a key resets state, and what Fiber and batching change about updates.

~7 min readread →
read ✓System Design · hard

Frontend System Design: Build an Autocomplete

A structured walkthrough of the autocomplete design round: requirements, architecture, race-free fetching, caching, rendering, the ARIA combobox and metrics.

~7 min readread →
esc