React Deep Technical Interviews: The Right Mental Model Beats Memorization
Bài này có bản tiếng Việt. Đọc bản tiếng Việt →
Nguyen Hai Trinh • Senior Software Engineer @ SmileViet
The React deep technical round often opens with a question that sounds harmless: “Is setState async?” Getting the syntax right is easy. Getting the mechanism right is the hard part.
Below are the ten questions I ran into most often from the interviewer’s chair. Each has three parts: what the interviewer asks, the middle-level answer that sounds reasonable but misses the mechanism, and the senior answer, which starts from the mental model and works out what happens as the system grows.
1. “Is setState async?”
The interviewer asks: “Is setState in React async?”
Middle answer: “Yes, it’s async, so you have to read the new value in a callback or in useEffect.” Wrong mechanism.
Senior answer: setState doesn’t return a Promise, and it doesn’t go through the microtask queue either. Two different things get lumped together here. Batching: multiple updates in the same tick are merged into one re-render. Snapshot semantics: the state variable you read is a const from the current render, so setCount(count + 1) followed by console.log(count) still prints the old value. Still the old one.
Since React 18, automatic batching also covers updates inside promises, setTimeout and native event handlers. Before 18, only updates inside React event handlers were batched. Any code that assumes “a render happens right after setState” will break on upgrade. If you need to read the DOM right after an update, flushSync is the opt-out, but using it often means you’re fighting React’s model.
import { flushSync } from "react-dom";
function handleAdd() {
setItems((prev) => [...prev, nextItem]);
console.log(items.length); // still the value from the current render
flushSync(() => setOpen(false)); // the DOM is updated after this line
panelRef.current?.scrollTo({ top: 0 });
}
2. Three setState calls in one handler
The interviewer asks: “If you call setCount(count + 1) three times in one handler, how much does count go up?”
Middle answer: “React batches them so it only renders once, and the value goes up by three.” The first half is right, the second half is wrong.
Senior answer: each setState call pushes an update onto a queue. All three updates read the count from the current render, so they all carry count + 1. React runs the queue in order, each one overwrites the previous, and the result is an increase of 1. To get +3, use the functional update setCount((c) => c + 1), so each updater receives the latest value already computed in the queue. That’s a direct consequence of the snapshot semantics from section 1.
“One re-render” also needs to be stated precisely. If the new value equals the old one under Object.is, React skips the update, but it may still render the component once more before bailing out. Don’t treat “setState with the same value = zero renders” as a contract. And for object/array state, Object.is compares references, so setState({ ...state }) always creates a new render.
// Wrong: three updates all read this render's count -> count = 1
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
// Right: each updater gets the latest value in the queue -> count = 3
setCount((c) => c + 1);
setCount((c) => c + 1);
setCount((c) => c + 1);
flowchart LR E["onClick"] --> A["setA(1)"] & B["setB(2)"] & C["setC(3)"] A --> Q["Batch queue"] B --> Q C --> Q Q --> R["ONE re-render<br/>React 18: even inside<br/>promise / setTimeout"]
3. Stale closure: the interval keeps logging the old value
The interviewer asks: “Why does my interval keep logging the old value of state?”
Middle answer: “It’s probably the dependency array. Add that variable to the deps and it goes away.” The symptom goes away, but for intervals and sockets that approach also causes incorrect re-subscribing.
Senior answer: on every render, React creates a new closure for the component. Every state variable inside it is a const from that render. A callback registered in the first render will keep seeing the first render’s data until it’s registered again. Stale closure is a consequence of snapshot semantics, not a bug unique to useEffect.
An effect with a missing dependency holds on to the old closure. exhaustive-deps exists to force you to see what the effect depends on, and when it depends on nearly everything, the effect is usually doing the wrong job. When you need the latest value without re-subscribing (a WebSocket handler, an interval, an external library’s subscriber), the latest-ref pattern is the right tool: keep the callback in a ref and update the ref in an effect. React 19.2 has useEffectEvent for exactly this case.
function useInterval(callback: () => void, delay: number | null) {
const saved = useRef(callback);
useEffect(() => {
saved.current = callback; // update in an effect, not during render
}, [callback]);
useEffect(() => {
if (delay === null) return;
const id = setInterval(() => saved.current(), delay);
return () => clearInterval(id);
}, [delay]); // only set the interval up again when delay changes
}
4. useEffect is not a lifecycle
The interviewer asks: “Is useEffect equivalent to componentDidMount or componentDidUpdate?”
Middle answer: “It’s both: empty deps is like componentDidMount, with deps it’s like componentDidUpdate.” This is the most common answer, and also the source of most effect bugs.
Senior answer: useEffect isn’t a point in a component’s lifecycle. It’s the place where you synchronize a system outside React with the current props/state. If what you’re doing doesn’t touch anything outside React (no DOM, network, subscription, or timer), you usually don’t need an effect. Compute derived values during render, and “state derived from props” is a design mistake.
Effects usually run after the browser paints. Cleanup runs before the next effect and once more on unmount. It undoes the previous synchronization, rather than being a “destructor for when the component dies”. In dev, StrictMode calls mount → unmount → mount to expose effects with missing cleanup: stacked subscriptions, requests that never get cancelled, leaking listeners. In production there is no “runs twice” bug.
useEffect(() => {
const connection = createConnection(roomId);
connection.connect();
return () => connection.disconnect(); // runs before the next effect and on unmount
}, [roomId]);
5. Race conditions when fetching in an effect
The interviewer asks: “How do you fetch data in useEffect?”
Middle answer: “I write an async function inside the effect and call setState when the result arrives.” It works, and that’s exactly where the race begins.
Senior answer: every time the deps change, a new request goes out, and nothing guarantees the responses come back in order. Typing “re”, “rea”, “react” creates three parallel requests. The one for “re” might come back last and overwrite the UI with the wrong result. This kind of last-write-wins is a classic production bug: on a dev machine with low latency you almost never reproduce it.
Two layers of defense, used together. AbortController cancels the request in flight (an abort makes fetch reject with AbortError), and an ignore flag blocks setState from a stale run. You still need the flag because abort is best-effort. The effect callback can’t be an async function, and errors must be handled inside the async function itself. At the system level, caching, dedupe, cancellation and stale-while-revalidate are infrastructure problems, and I leave those to TanStack Query.
useEffect(() => {
const controller = new AbortController();
let ignore = false;
async function load() {
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
signal: controller.signal,
});
const data = await res.json();
if (!ignore) setResults(data);
} catch (err) {
if ((err as Error).name !== "AbortError") setError(err as Error);
}
}
load();
return () => {
ignore = true; // block setState from a stale run
controller.abort();
};
}, [query]);
6. useMemo, useCallback and memo: when they’re worth it
The interviewer asks: “When do you use useMemo?”
Middle answer: “To avoid recomputing, and I wrap things in useCallback/memo so child components don’t re-render.” That misses both the cost and the limits of the contract.
Senior answer: the React docs themselves say: “You may rely on useMemo as a performance optimization, not as a semantic guarantee.” React can throw away a cached value at any time, so the code must be correct even if useMemo didn’t exist. If correctness depends on it (creating an instance, registering once, holding a business identity), that’s a design bug. The right tools are useRef, the lazy initializer of useState, or a module-scope variable.
And useMemo doesn’t make the first render faster. It only skips work on later updates, and it has its own cost: holding the dependency array, comparing it on every render, keeping the closure. memo only helps if props are stable; if the parent passes a new object/function every render, it renders as usual.
// No memo needed: cheap derived value
const total = items.reduce((sum, item) => sum + item.price, 0);
// Memo is worth it: expensive computation and stable deps
const ranked = useMemo(
() => rows.slice().sort((a, b) => b.score - a.score),
[rows],
);
7. useState vs useRef: the difference is the contract
The interviewer asks: “How is useRef different from useState?”
Middle answer: “useRef is for grabbing a DOM node, useState is for storing a value.” Half of the uses are right, but it misses the point.
Senior answer: a ref is an escape hatch for two things. One, pointing at a DOM node. Two, holding a mutable value that survives across renders without triggering a re-render. The difference is the contract: state takes part in rendering, so when state changes the UI can be described again. Changing a ref does nothing, so the UI must not depend on it.
That’s why you shouldn’t read or write ref.current during render. Render has to be pure, because in concurrent mode React may call render several times, rerun it, or throw the result away. A ref write during render would run an unpredictable number of times. The only exception is lazy init: if ref.current === null, assign it once. Writing a ref in an event handler or an effect is fine, and the latest-ref in section 3 is an example.
// Wrong: mutating during render, with an unpredictable run count in concurrent/StrictMode
function Bad() {
const renders = useRef(0);
renders.current += 1;
return <p>{renders.current}</p>;
}
8. Context: when the value changes, every consumer re-renders
The interviewer asks: “Does context make every component re-render?”
Middle answer: “No, only the components that call useContext.” Half right, and it misses the important part.
Senior answer: to be precise: when the Provider’s value changes, every consumer of that context re-renders, whether or not it uses the part that just changed. A context that bundles user, theme and cart will re-render all of its consumers when a single field changes. A component in between that doesn’t call useContext won’t re-render, but it still gets reconciled when its parent renders.
One thing that often surprises people: a value created inline like value={{ user, theme }} is a new reference on every Provider render, so consumers re-render even though the data is identical. This is the case where useMemo on the value actually matters.
Fix it incrementally: split contexts by how often they change, memoize the value, and for high-frequency updates drop the broadcast in favor of a store with selectors. useSyncExternalStore registers each subscriber individually, so a component only re-renders when the part it selected changes. Stores like Zustand exist not because context is slow, but because context has no selectors.
// Every Provider render makes value a new object -> every consumer re-renders
<UserContext.Provider value={{ user, theme, unread }}>…</UserContext.Provider>
// Split by how often it changes: unread changes constantly, user/theme don't
<UserContext.Provider value={user}>
<ThemeContext.Provider value={theme}>
<UnreadContext.Provider value={unread}>…</UnreadContext.Provider>
</ThemeContext.Provider>
</UserContext.Provider>
9. Concurrent rendering, interrupted renders and tearing
flowchart TB U["User input<br/>(high priority)"] --> R1["Urgent render"] T["useTransition / useDeferredValue<br/>(low priority)"] --> R2["Render that can be<br/>interrupted & restarted"] R2 -. "interrupted" .-> R3["Render again from scratch<br/>→ render must be PURE"] R1 --> CM["Commit: mutate DOM"] R3 --> CM CM --> UI["UI updates"] EXT["External store"] --> SE["useSyncExternalStore<br/>prevents tearing"] SE --> UI
The interviewer asks: “What problem does concurrent rendering in React 18 solve?”
Middle answer: “It makes rendering faster, and useTransition is for debouncing input.” Both halves are wrong.
Senior answer: concurrent rendering doesn’t make rendering faster. React starts a render, can be interrupted by a more urgent update, and then starts over. The trade-off is that render must be pure: any mutation of outside variables during render (writing a ref, mutating a module-level variable, incrementing a counter) gives nondeterministic results when the render is rerun or discarded.
Tearing is the risk when React reads from a data source outside React: two components reading the same store can receive two different versions of the state in the same frame. useSyncExternalStore is the standard API to avoid it. React subscribes to notifications from the store and rechecks the snapshot while rendering.
useTransition and useDeferredValue mark updates as non-urgent. One sentence to lock in: a transition is a prioritization mechanism, not a debounce. It doesn’t wait X milliseconds, doesn’t merge several keystrokes into one run, doesn’t cancel requests. All it does is let React abandon this render if something more urgent comes up.
const [input, setInput] = useState("");
const [filter, setFilter] = useState("");
const [isPending, startTransition] = useTransition();
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
setInput(e.target.value); // urgent: the input must match the keystroke immediately
startTransition(() => {
setFilter(e.target.value); // non-urgent: allowed to be interrupted
});
}
10. Custom hooks and lazy initializers: who owns the state?
The interviewer asks: “Do two components using the same custom hook share state? And what’s the difference between useState(() => init()) and useState(init())?”
Middle answer: “Sharing a hook shares logic; lazy init makes it run faster.” The first half is wrong on the word state.
Senior answer: a custom hook is just a function that calls other hooks. It reuses logic, not state. Each time a component calls the hook, the state and effects inside belong to that component’s fiber, so two components calling the same hook get two independent areas of state. To actually share state you have to lift it to a parent, or use context or an external store. Hooks must be called unconditionally at the top level, because React matches hooks by call order.
As for the lazy initializer: useState(expensive()) calls expensive on every render and throws the result away after the first, so you still pay the cost every render. useState(() => expensive()) passes a function, and React only calls it when it needs the initial value, once per mount, and the init function must be pure. For an init that reads localStorage or builds a map from a long list, the difference is measurable in the Profiler.
// Runs every render, the result is discarded after the first
const [index] = useState(buildSearchIndex(rows));
// Runs only once per mount
const [index2] = useState(() => buildSearchIndex(rows));
Wrapping up: key, identity and how to answer
A common screening question: “Why does the input break in a list after sorting?” The answer has to start from identity in reconciliation. React matches elements by position in the tree and by key, so key is the identity of the instance. key={index} is fine when the list only appends at the end, but when you reorder or sort, the element at position 1 becomes a different element while its key is still 1. React treats it as the same instance and keeps the old state, so the input ends up attached to the wrong item. Changing the key forces a new instance to mount, which is the cleanest way to reset a form’s state when the user changes. I covered the matching tree in detail in the deep technical post on reconciliation on this blog.
The framework I use is still four steps: what React’s mental model is, the behavior that follows from that model, what happens as the system grows, and how to verify it. Senior interviewers dig with “so what if …”, and answering with API names alone runs out of road fast. The LCP/INP/CLS measurement side I wrote up separately in the render performance post.
Keep reading
Need a developer for a contract or freelance project?
I take on fullstack projects — Go backends, React/Next.js frontends, or both ends. Send a few lines about the scope and timeline and I'll reply within 48 hours.