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 changesReact 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>;
}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
}Gotcha
Effect Events are only for logic that is genuinely an “event” fired from an effect. Don’t use
useEffectEvent, or an// eslint-disablecomment, 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);
}, []);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]);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]);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 generalThis 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
useRefflag 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} />;
}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]);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);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>;
}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.”