ReactIntermediate

React Interview: Hooks Deep Dive & the Traps

Every hook you will be asked about and every trap that comes with it — batching, stale closures, effect dependencies and cleanup, useRef vs useState, useMemo vs useCallback, custom hooks, and the rules of hooks with the reason behind them.

4 sections · ~36 min · 5-question quiz (pass ≥ 70%)

1useState: Batching, Updater Functions, and Lazy Init

State updates are asynchronous and batched. Within an event handler, React collects all updates and re-renders once.

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

function handleClick() {
  setCount(count + 1);
  setCount(count + 1);
  setCount(count + 1);
  console.log(count);   // 0 — this render's count is a constant, not a live value
}
// Result: count becomes 1, not 3.

All three calls read the same count from the current render's closure. This is the single most-asked React interview question.

The fix — the updater form, which receives the latest pending value:

setCount(c => c + 1);
setCount(c => c + 1);
setCount(c => c + 1);   // now 3

Rule: whenever the next state depends on the previous state, use the updater function. Always.

Since React 18, batching applies everywhere — including inside promises, setTimeout, and native handlers ("automatic batching"), not just React event handlers as in React 17.

Lazy initialisation — the initial value argument is evaluated on every render even though it is only used once:

const [state, setState] = useState(expensiveInit());     // runs every render
const [state, setState] = useState(() => expensiveInit()); // runs once

Storing a function in state needs the same care: setFn(() => myFunction), because a bare function argument is interpreted as an updater.

Bail-out: setting state to the same value (by Object.is) lets React skip the re-render — another reason immutable updates with new references matter.

2useEffect: Dependencies, Cleanup, and Not Overusing It

useEffect synchronises your component with something outside React — a subscription, a timer, a network request, the document title. It is not a lifecycle hook, and the mental shift away from "componentDidMount" is what interviewers are probing.

The three dependency forms:

useEffect(() => { /* after every render */ });
useEffect(() => { /* once after mount */ }, []);
useEffect(() => { /* when userId changes */ }, [userId]);

Cleanup runs before the next effect and on unmount — this is where leaks are prevented:

useEffect(() => {
  const id = setInterval(tick, 1000);
  return () => clearInterval(id);          // without this, intervals stack up
}, []);

useEffect(() => {
  const controller = new AbortController();
  fetch(`/api/users/${userId}`, { signal: controller.signal })
    .then(r => r.json())
    .then(setUser)
    .catch(e => { if (e.name !== "AbortError") setError(e); });
  return () => controller.abort();          // cancels the stale request
}, [userId]);

That second example also solves the race condition question: without the abort (or an ignore flag), a slow response for an old userId can land after a fast one for the new id and overwrite it.

Stale closure trap — an effect captures the values from the render it was created in:

useEffect(() => {
  const id = setInterval(() => setCount(count + 1), 1000);  // count is frozen at 0
  return () => clearInterval(id);
}, []);                                                      // ...forever

Fix with the updater form (setCount(c => c + 1)) or by including count in the deps.

Do NOT use an effect for:

  • Transforming data for rendering — compute it during render.
  • Handling a user event — put that logic in the event handler.
  • Syncing one piece of state to another — derive it instead.

Strict Mode in development mounts, unmounts, and remounts every component, running effects twice on purpose. That is a feature: it surfaces missing cleanup. Do not "fix" it with a ref guard.

useLayoutEffect runs synchronously after DOM mutation but before paint — use it only when you must measure the DOM and adjust before the user sees a flicker.

3useRef, useMemo, useCallback — and When Not to Use Them

useRef is a mutable box whose .current persists across renders and does not trigger a re-render when changed. Two uses:

const inputRef = useRef(null);            // 1. DOM access
<input ref={inputRef} />
inputRef.current.focus();

const timerRef = useRef(null);            // 2. instance variable
timerRef.current = setInterval(...);

useRef vs useState: change a ref → nothing re-renders, and the new value is readable immediately. Change state → a re-render is scheduled, and the current render still sees the old value. Rule: if it should appear on screen, it is state; if it is bookkeeping, it is a ref.

useMemo caches a computed value; useCallback caches a function reference. useCallback(fn, deps) is exactly useMemo(() => fn, deps).

const sorted = useMemo(
  () => bigList.sort((a, b) => a.score - b.score),
  [bigList]
);

const handleSelect = useCallback((id) => setSelected(id), []);

They only pay off in three situations:

  1. The computation is genuinely expensive (sorting/filtering thousands of rows).
  2. The value is passed to a React.memo child — otherwise a new reference every render defeats the memoization entirely.
  3. The value is an effect dependency, and an unstable reference would cause the effect to run every render.

Outside those, memoization costs you: extra allocation, extra comparisons, and code that is harder to read. Saying "I'd measure with the Profiler before memoizing" is a better answer than "I memoize everything".

React.memo skips a re-render when props are shallowly equal. It is defeated by inline objects, arrays, and functions:

<Child style={{ color: "red" }} onClick={() => go()} />   // new refs every render

React 19's Compiler auto-memoizes much of this. Mentioning it — while showing you understand the underlying reference-identity problem — is a strong signal.

4Custom Hooks, useReducer, and the Rules of Hooks

The Rules of Hooks: call hooks only at the top level (never inside conditions, loops, or nested functions) and only from React function components or other hooks.

Why? React tracks hooks by call order, not by name — internally it is a linked list indexed positionally per component. A conditional hook shifts every subsequent index, and useState #2 starts returning useState #3's value.

if (isLoggedIn) {
  const [name, setName] = useState("");   // BREAKS the order between renders
}

Custom hooks are just functions starting with use that call other hooks. They share logic, not state — each caller gets its own independent state. Expect to write one live:

function useDebounce(value, delay = 300) {
  const [debounced, setDebounced] = useState(value);
  useEffect(() => {
    const id = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(id);         // cancel on every keystroke
  }, [value, delay]);
  return debounced;
}

function useFetch(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    let ignore = false;
    setLoading(true);
    fetch(url)
      .then(r => { if (!r.ok) throw new Error(r.status); return r.json(); })
      .then(d => { if (!ignore) setData(d); })
      .catch(e => { if (!ignore) setError(e); })
      .finally(() => { if (!ignore) setLoading(false); });
    return () => { ignore = true; };       // ignore stale responses
  }, [url]);

  return { data, loading, error };
}

useReducer — reach for it when the next state depends on the previous one in non-trivial ways, when several fields update together, or when the update logic is worth testing in isolation:

function reducer(state, action) {
  switch (action.type) {
    case "increment": return { ...state, count: state.count + 1 };
    case "reset":     return initialState;
    default:          throw new Error("Unknown action: " + action.type);
  }
}
const [state, dispatch] = useReducer(reducer, initialState);

The reducer is a pure function of (state, action), which makes it trivially unit-testable — that is the answer to "when would you pick useReducer over useState?"

Also know by name: useContext (read context), useId (stable SSR-safe ids for form labels), useTransition (mark an update non-urgent so typing stays responsive), useDeferredValue, and useSyncExternalStore (subscribe to a store outside React).

Ready to test yourself?

Sign in to take the quiz, track progress, and earn a certificate.

Sign in