{}The Interview
Handbook

Tracks / React

Core React & hooks

junior 11 questions · 5 min read hooksstaterendering

Questions in this set 11
  1. 01What is the virtual DOM, and is it "faster than the DOM"?
  2. 02Why do lists need key, and why is the array index a bad key?
  3. 03useState — explain batching, the functional updater, and lazy initialisation.
  4. 04When does a React component re-render?
  5. 05useEffect: explain the dependency array, the cleanup function, and when not to use an effect.
  6. 06Why does my effect run twice on mount in development?
  7. 07useMemo vs useCallback vs React.memo.
  8. 08Controlled vs uncontrolled components.
  9. 09What are the Rules of Hooks and why do they exist?
  10. 10What is useRef for, and how does it differ from state?
  11. 11useReducer vs useState — when do you switch?
01

What is the virtual DOM, and is it "faster than the DOM"?

The virtual DOM is a lightweight tree of plain objects describing the UI. On a state change React builds a new tree, diffs it against the previous one (reconciliation), and applies the minimum set of real DOM mutations.

Answer the second half honestly: no, it is not inherently faster than hand-written, perfectly targeted DOM updates. What it buys you is a declarative model — you describe the target state and React works out the transitions — with performance that is good enough and predictable. Frameworks like Svelte and Solid get the same declarative benefit by compiling to fine-grained updates with no diffing at all. Saying this shows you understand the trade-off rather than reciting marketing.

02

Why do lists need key, and why is the array index a bad key?

Keys let React match elements between renders. Without them it matches by position, so an insertion at the top makes every subsequent element look "changed", destroying and recreating DOM and component state.

jsx
{todos.map((t, i) => <Todo key={i} {...t} />)}      // BUG on insert/delete/reorder
{todos.map(t => <Todo key={t.id} {...t} />)}        // correct: stable identity

The concrete symptom: you type into the third input, delete the first row, and your text is now attached to the wrong item — because the index-keyed component was reused with different data while keeping its own state.

Index keys are acceptable only for a list that is static, never reordered and never filtered.

03

useState — explain batching, the functional updater, and lazy initialisation.

jsx
const [count, setCount] = useState(() => expensiveInit());   // lazy: runs once, not every render

setCount(count + 1);
setCount(count + 1);          // both read the same stale `count` -> +1 total
setCount(c => c + 1);
setCount(c => c + 1);         // functional form queues on latest -> +2

React 18 batches all updates (including in promises, timeouts and native handlers) into one re-render; React 17 batched only inside React event handlers. State updates are asynchronous with respect to your function body — reading count right after setCount gives the old value, because count is a const captured by this render's closure.

04

When does a React component re-render?

Three reasons: its own state changed, its context value changed, or its parent re-rendered. Note the third — props do not have to change. A parent re-render re-renders the whole subtree unless a component is memoised or the subtree is passed as children (a stable element reference).

jsx
// This subtree does NOT re-render when Parent's state changes, because <Heavy/> was
// created in the grandparent and passed through as a prop.
function Parent({ children }) {
  const [n, setN] = useState(0);
  return <div onClick={() => setN(n + 1)}>{n}{children}</div>;
}

That "children as a stable slot" trick is a strong senior-level answer, and it is often better than React.memo.

05

useEffect: explain the dependency array, the cleanup function, and when not to use an effect.

jsx
useEffect(() => {                 // runs after paint
  const c = new AbortController();
  fetchUser(id, { signal: c.signal }).then(setUser).catch(ignoreAbort);
  return () => c.abort();         // cleanup: on unmount AND before every re-run
}, [id]);                          // [] = mount only; omitted = every render

Cleanup exists because effects re-run: if id changes twice quickly, without abort the slower first response can overwrite the newer one.

The more important half of the question — when not to use an effect:

  • Transforming data for rendering → compute during render (const visible = items.filter(...)), memoise only if measurably slow.
  • Responding to a user event → do it in the event handler, not an effect.
  • Resetting state when a prop changes → change the component's key instead.
  • Fetching data → prefer a framework loader, React Query/SWR, or a Server Component. Raw useEffect fetching gives you no caching, no dedup, no retries, and a race condition per request.

Effects are for synchronising with systems outside React: subscriptions, timers, the DOM, analytics.

06

Why does my effect run twice on mount in development?

Strict Mode in React 18+ deliberately mounts, unmounts and remounts every component in dev to surface missing cleanup. It does not happen in production. If double-invocation breaks your code, the cleanup function is wrong or the effect is not idempotent — the tool is doing its job.

07

useMemo vs useCallback vs React.memo.

  • useMemo(fn, deps) caches a value.
  • useCallback(fn, deps) caches a function identity — it is useMemo(() => fn, deps).
  • React.memo(Component) skips a re-render when props are shallowly equal.

They only work together: memoising a callback is pointless unless the child is memo-wrapped, and memo is defeated by any inline object/array/function prop.

jsx
const Child = React.memo(function Child({ onPick, config }) { … });

const onPick  = useCallback(id => select(id), []);
const config  = useMemo(() => ({ dense: true }), []);   // without this, memo never hits

Say the caveat: memoisation is not free (allocation + comparison), and the honest default is to not memoise until profiling shows a problem. The React Compiler (React 19) automates most of this.

08

Controlled vs uncontrolled components.

Controlled: React state is the single source of truth (value + onChange) — needed for validation as you type, formatting, dependent fields. Uncontrolled: the DOM holds the value, read via ref or FormData on submit — less code, fewer renders, and the right default for simple forms.

jsx
function Form() {                             // uncontrolled, no re-render per keystroke
  return <form onSubmit={e => {
    e.preventDefault();
    const data = Object.fromEntries(new FormData(e.currentTarget));
  }}><input name="email" defaultValue="" /></form>;
}

Common bug: passing value={undefined} initially then a real value later — React warns that the input switched from uncontrolled to controlled. Initialise with "".

09

What are the Rules of Hooks and why do they exist?

Call hooks only at the top level (never in conditions, loops or nested functions) and only from React functions. React stores hook state in an ordered list per component; calling them conditionally shifts the indices, so useState #2 returns hook #3's value. That is the whole reason — say it, rather than reciting the rule.

10

What is useRef for, and how does it differ from state?

A ref is a mutable box ({ current }) that persists across renders and does not trigger a re-render when changed. Uses: DOM access (<input ref={inputRef}>), storing timer/interval ids, holding the previous value, and any mutable value that is not rendered (a websocket, a counter for logging). If the value appears in your JSX, it needs to be state, not a ref.

11

useReducer vs useState — when do you switch?

Switch when the next state depends on the previous one in non-trivial ways, when several fields must change together (avoiding impossible states like loading && error), or when the update logic is worth unit-testing on its own. A reducer also lets you pass a stable dispatch down instead of many callbacks, which plays well with context and memoisation.

jsx
function reducer(state, action) {
  switch (action.type) {
    case "submit":  return { status: "loading", error: null, data: null };
    case "success": return { status: "done",    error: null, data: action.data };
    case "failure": return { status: "error",   error: action.error, data: null };
  }
}