React Interview Questions, Actually Explained
Most "React interview questions" lists are answer keys — a question, three sentences, next. That's fine for a five-minute refresher, but it's useless for actually understanding why the answer is the answer. This is my attempt at the other version: the same territory a typical React interview bank covers (I used the well-known Devinterview.io React question set as a map of what to include), rewritten in my own words, with the reasoning spelled out, real code, and a few diagrams for the parts that are easier to see than to read. Organized by topic, so skip to what you need.
Core concepts
Q1. What is the Virtual DOM, and why does React bother with one?
The real DOM is expensive to touch — every mutation can trigger layout recalculation, style recomputation, and repainting. React's answer is to keep a lightweight, plain-JavaScript description of the UI (the "virtual" DOM) and do all the expensive comparison work there first. When state changes, React builds a new virtual tree, diffs it against the previous one, and only then applies the minimal set of real DOM operations needed to reconcile the difference. You're trading cheap JS-object comparisons for expensive DOM operations, which is a good trade almost everywhere in a UI.
What happens between a state update and a repaint
JSX
Compiled to React.createElement calls
Virtual DOM
New element tree, plain JS objects
Reconciler
Diffs new tree vs. previous tree
Real DOM
Minimal patch, then the browser paints
Q2. JSX vs. React.createElement — what is JSX actually compiling to?
JSX isn't HTML and it isn't magic — it's syntactic sugar that a compiler (Babel, or the Next.js/SWC toolchain) turns into plain function calls before your code ever runs in the browser. Newer React versions compile JSX to a jsx() runtime call instead of createElement directly, but the mental model is identical: JSX is just a nicer way to write a tree of function calls that each describe one element.
// What you write:
function Welcome({ name }) {
return <h1 className="title">Hello, {name}</h1>;
}
// Roughly what the compiler produces:
function Welcome({ name }) {
return React.createElement(
"h1",
{ className: "title" },
"Hello, ",
name,
);
}That's also why a component's name must start with a capital letter — createElement("div", ...) treats a lowercase tag as a plain HTML tag string, while an uppercase identifier is treated as a reference to a function/class to call.
Q3. Why do list items need a stable key, and why is array index a bad one?
The diffing algorithm compares old and new children lists positionally by default. A key tells React "this specific item persisted across renders, even if its position changed" — without it (or with an unstable one like the array index), React can't tell a reorder from a delete-and-recreate, so it may unmount/remount DOM nodes it didn't need to, losing input focus, animation state, or uncontrolled form values in the process.
// Fragile: if the list is reordered or filtered, index-as-key makes
// React match the WRONG previous element to each new position.
todos.map((todo, i) => <TodoRow key={i} todo={todo} />);
// Correct: key follows the actual entity, wherever it ends up.
todos.map((todo) => <TodoRow key={todo.id} todo={todo} />);- Index keys are only safe for lists that are static — never reordered, filtered, or have items inserted/removed.
- A key only needs to be unique among siblings, not globally.
- Keys aren't passed down as a prop — if the child needs the id too, pass it explicitly.
Components, props & state
Q4. Function components vs. class components — is there still a reason to reach for a class?
Practically, no — for new code. Hooks (introduced in React 16.8) gave function components everything classes could do, with less boilerplate and no this-binding footguns. The one real gap: there is still no hook equivalent for componentDidCatch/getDerivedStateFromError, so error boundaries remain classes. Everything else below is why function + hooks won by default.
| Function + Hooks | Class | |
|---|---|---|
| State | useState / useReducer | this.state + this.setState |
| Side effects | useEffect (one API, all phases) | 3 separate lifecycle methods |
| Sharing logic | Custom hooks — compose freely | HOCs / render props — wrapper hell |
| this binding | Not a concern | Common source of bugs |
| Error boundaries | Not supported | Only option (componentDidCatch) |
Q5. Props vs. state — what's the actual line between them?
Props are how a parent configures a child — they flow one way, down, and a component must never mutate its own props. State is data a component owns and manages itself, and changing it (via setState/useState's setter) is what triggers a re-render. A useful test: if the value is handed to you from outside, it's a prop; if you're the one deciding when it changes, it's state. A very common bug is copying a prop into state with useState(prop) and expecting it to stay in sync — it won't, because the initial value is only read once.
Q6. Controlled vs. uncontrolled inputs
| Controlled | Uncontrolled | |
|---|---|---|
| Source of truth | React state (value + onChange) | The DOM itself |
| Reading the value | Always available in state | Pull it on demand via a ref |
| Validation as you type | Trivial | Needs manual event wiring |
| Typical use | Forms with live validation/formatting | Simple forms, file inputs, quick prototypes |
// Controlled — React owns the value, every keystroke is a state update.
function ControlledInput() {
const [value, setValue] = useState("");
return <input value={value} onChange={(e) => setValue(e.target.value)} />;
}
// Uncontrolled — the DOM owns the value; React reads it only when asked.
function UncontrolledInput() {
const ref = useRef(null);
const handleSubmit = () => console.log(ref.current.value);
return <input ref={ref} defaultValue="" />;
}Hooks in depth
Q7. useState — why does setCount(count + 1) called twice in a row only add 1, not 2?
Inside a single event handler, count is a constant captured from that render's closure — it doesn't change between the two calls. setCount(count + 1) run twice just tells React "set it to count+1" twice, which is the same instruction repeated, not two increments. The functional-update form fixes this by receiving the latest pending value instead of the stale closed-over one.
// Bug: both calls read the same stale `count` from this render's closure.
setCount(count + 1);
setCount(count + 1); // count is STILL +1, not +2
// Fix: the updater function always receives the latest queued value.
setCount((c) => c + 1);
setCount((c) => c + 1); // now genuinely +2Q8. useEffect — what actually goes wrong with the dependency array?
useEffect re-runs its callback whenever any value in the dependency array changed since the last render (by ===). The two mistakes that show up constantly: omitting a dependency the effect actually reads (stale-data bugs), and putting an object/array/function literal in the array that gets a new reference every render (infinite-loop bugs, since a new reference always counts as "changed"). The cleanup function returned from the effect runs before the next execution and on unmount — it's the one place to undo subscriptions, timers, or listeners so they don't leak.
useEffect(() => {
const id = setInterval(() => setTick((t) => t + 1), 1000);
// Cleanup — without this, every re-render (or unmount) leaves the old
// interval running in the background, ticking a state setter for a
// component that may no longer exist.
return () => clearInterval(id);
}, []); // empty array = run once on mount, clean up once on unmount- If the effect doesn't read any props/state, [] is correct and safe.
- If it does, list every one of them — the exhaustive-deps ESLint rule exists because "I know better" is usually the bug.
- An effect that only responds to a click shouldn't be an effect at all — put that logic directly in the event handler.
Q9. useMemo vs. useCallback — do you actually need either of them?
Both exist to preserve a stable reference across renders — useMemo memoizes a computed value, useCallback memoizes a function (and is literally useMemo(() => fn, deps) under the hood). Neither one is free: they cost a comparison and a cache slot every render. They earn their keep in exactly two situations — an expensive computation you don't want to redo every render, or a value/function passed as a prop to a React.memo-wrapped child, where a new reference every render would defeat the memoization entirely.
| useMemo | useCallback | |
|---|---|---|
| Memoizes | A computed value | A function reference |
| Typical use | Expensive derived data (sort, filter, aggregate) | Callback passed to a memoized child |
| Equivalent to | — | useMemo(() => fn, deps) |
| Skip it when | The computation is cheap | Nothing downstream depends on referential stability |
const sorted = useMemo(
() => [...items].sort((a, b) => a.price - b.price),
[items],
);
// Stable reference so <ExpensiveChild /> (wrapped in React.memo) doesn't
// re-render just because the parent re-rendered with a fresh arrow function.
const handleSelect = useCallback((id) => setSelectedId(id), []);
return <ExpensiveChild items={sorted} onSelect={handleSelect} />;Q10. useRef — what is it for, beyond grabbing a DOM node?
A ref is a mutable box that survives re-renders without causing one when it changes — the opposite of state. Past DOM access, that makes it the right tool for anything you need to remember across renders but never want to render: a previous value for comparison, a mutable counter, an interval/timeout id, or a flag like "has this already run."
// A classic custom hook: track the PREVIOUS render's value.
function usePrevious(value) {
const ref = useRef();
useEffect(() => {
ref.current = value; // runs AFTER render, so it always lags by one
}, [value]);
return ref.current;
}Want the full map?
This deep-dive covers 36 questions in depth. The companion piece covers all 100 — every topic, concise and to the point.
Enjoyed this?
Let's talk about building something together.