#Rendering and state
These questions check the core loop: a render is a snapshot of props and state, setters queue updates instead of changing variables, and React decides whether to keep or reset state by a component's position and key. Answer each one out loud before you open it. The first paragraph of each answer is what you'd say in the interview; the walkthrough is the reasoning behind it. Every predict-the-output question was run on React 19.3 with Vitest and React Testing Library, without <StrictMode> unless the question says so (Strict Mode renders twice in development, which doubles render logs).
function Counter() {
const [count, setCount] = useState(0);
function handleClick() {
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
}
return <button onClick={handleClick}>{count}</button>;
}What does the button show after one click? After two?
Show answer
1 after one click, 2 after two. Each click adds one, not three.
- On the first render
countis0.handleClickwas created during that render, so inside itcountis the constant0. It's a snapshot, not a live variable. - The three calls are
setCount(0 + 1)three times. Each one queues "replace state with 1". Nothing re-renders in between: updates in an event handler are batched. - After the handler, React processes the queue:
1, then1, then1. One re-render, and the button shows1. - The second click runs the new render's handler, where
countis1, so it queues2three times and shows2. - To add three, pass an updater:
setCount((c) => c + 1)three times. Each updater receives the result of the previous one (0 → 1 → 2 → 3).
Learn more: State updates are batched and Functional updates and stale closures.
function Counter() {
const [count, setCount] = useState(0);
return (
<>
<button onClick={() => { setCount(count + 5); setCount((c) => c + 1); }}>A</button>
<button onClick={() => { setCount((c) => c + 1); setCount(42); }}>B</button>
<p>{count}</p>
</>
);
}Click A, then B. What does the paragraph show after each?
Show answer
6 after A, then 42 after B.
- React keeps a queue of updates per state and replays it in order. A value means "replace with this"; a function means "compute from the previous result".
- Click A with
count = 0: the queue is[replace with 5, c => c + 1]. Replaying from0:5, then5 + 1 = 6. One render shows6. - Click B with
count = 6: the queue is[c => c + 1, replace with 42]. Replaying:7, then42. The plain value at the end wins. - So the order of calls matters, and a plain value throws away everything queued before it. Mixing the two styles in one handler is legal but easy to misread.
Learn more: Functional updates and stale closures.
function Counter() {
const [count, setCount] = useState(0);
function handleClick() {
setCount(count + 1);
console.log("right after:", count);
setTimeout(() => console.log("1 s later:", count), 1000);
}
return <button onClick={handleClick}>{count}</button>;
}Show answer
It logs right after: 0 and then 1 s later: 0, while the button shows 1.
setCount(count + 1)doesn't change thecountvariable. It asks React to render again with1. The current function call keeps its owncount, which isconstand equal to0.console.log("right after:", count)runs in the same call, so it prints0.- React re-renders after the handler, and the button shows
1. That render has a newcountvariable equal to1, in a new function call. - The timeout callback was created by the first render's handler, so it closed over that render's
count. A second later the state is1, but the closure still sees0. This is the "stale closure" problem. - To read the latest value later, use an updater (
setCount((c) => …)), a ref that you keep in sync, or compute the next value first:const next = count + 1; setCount(next); console.log(next);.
Learn more: useState and Functional updates and stale closures.
function TodoList() {
const [todos, setTodos] = useState(["Learn hooks"]);
function add() {
todos.push("Write tests");
setTodos(todos);
}
return (
<>
<button onClick={add}>Add</button>
<ul>{todos.map((t, i) => <li key={i}>{t}</li>)}</ul>
</>
);
}Show answer
push mutates the existing array and then passes the same reference back to setTodos. React compares old and new state with Object.is, sees the same array, and skips the re-render. In the test the list stayed at 1 item after clicking Add, and then jumped to 2 when an unrelated state update re-rendered the component.
todos.push(...)changes the array in place. The array now holds two items, but it's still the same object.setTodos(todos)hands React that same object.Object.is(oldTodos, newTodos)istrue, so React bails out: no render, and the screen still shows one item.- The data and the UI are now out of sync. The next time anything re-renders this component (in the test, a second button that updated another piece of state),
todos.mapreads the mutated array and the "missing" item suddenly appears. Bugs like this look random. - The fix is to create a new array:
setTodos([...todos, "Write tests"]), orsetTodos((prev) => [...prev, "Write tests"]). The new reference tells React something changed. - The same rule applies to objects (
{ ...user, name }) and to Redux reducers. Immer (and Redux Toolkit, which uses it) lets you writepushsafely because it produces a new copy behind the scenes.
Learn more: Immutability: why mutation breaks re-renders and Immer for nested updates.
function List() {
const [items, setItems] = useState(["Apple", "Banana"]);
return (
<>
<button onClick={() => setItems(["Cherry", ...items])}>Add to top</button>
{items.map((item, index) => (
<div key={index}>
<label>{item} <input /></label>
</div>
))}
</>
);
}You type "red" into Apple's input, then click "Add to top". Which input holds "red"?
Show answer
Cherry's. The test read Cherry="red" Apple="" Banana="". The text moved to the wrong row.
- Before the click, the rows are key
0→ Apple (its input holds "red") and key1→ Banana. The input's value lives in the DOM, not in React state, because the input is uncontrolled. - After the click,
itemsis["Cherry", "Apple", "Banana"], so the keys are0→ Cherry,1→ Apple,2→ Banana. - React matches old and new children by key. Key
0existed before, so React reuses that<div>and its<input>, and only updates the label text from "Apple" to "Cherry". The DOM input still holds "red". - Key
1gets its label changed from "Banana" to "Apple" and keeps its empty input. Key2is new, so a fresh Banana row with an empty input is created. - With
key={item}(or a real id), React would see Apple's key move to position 1 and move its DOM node, so "red" would stay with Apple. Index keys are only safe for lists that never reorder, insert or delete in the middle.
Learn more: Rendering lists and keys.
function Inbox({ messages }) {
return <div>{messages.length && <p>You have {messages.length} messages</p>}</div>;
}
// <Inbox messages={[]} />
// <Inbox messages={["hi", "yo"]} />Show answer
With no messages it renders <div>0</div>: a stray zero on the page. With two messages it renders <div><p>You have 2 messages</p></div>.
a && breturnsawhenais falsy, without evaluatingb.messages.lengthis0, which is falsy, so the expression's value is0, notfalse.- React skips
false,null,undefinedandtruewhen rendering children, but numbers are rendered, including0. So the0appears as text. - With two messages,
2 && <p>…</p>returns the<p>, which renders normally. - Fixes: make the left side a real boolean (
messages.length > 0 && …), or use a ternary (messages.length ? <p>…</p> : null). The same trap appears withcount && …andprice && …whenever the number can be0.
Learn more: Conditional rendering.
function Name() {
const [name] = useState("Rohit");
return <input aria-label="Name" value={name} />;
}A user types "xyz" into the field. What's in it, and what does React say?
Show answer
It still holds "Rohit": the field is read-only. In development React warns: "You provided a value prop to a form field without an onChange handler. This will render a read-only field. If the field should be mutable use defaultValue. Otherwise, set either onChange or readOnly."
- Passing
valuemakes this a controlled input: React owns its value, and after every render it forces the DOM value back to thevalueprop. - Each keystroke briefly changes the DOM value, but nothing updates
name, so React restores "Rohit". From the user's side, typing does nothing. - React notices
valuewithoutonChange(orreadOnly) and prints the warning once, on the first render. - The fixes match the warning's suggestions: add
onChange={(e) => setName(e.target.value)}for a controlled field, usedefaultValue="Rohit"for an uncontrolled one, or addreadOnlyif read-only is what you meant.
Learn more: Controlled vs uncontrolled components.
function Score({ player }) {
const [score, setScore] = useState(0);
return <button onClick={() => setScore(score + 1)}>{player}: {score}</button>;
}
function Game() {
const [first, setFirst] = useState(true);
return (
<>
{first ? <Score player="Alice" /> : <Score player="Bob" />}
<a onClick={() => setFirst(!first)}>Switch</a>
</>
);
}Click the score three times, then Switch. What does the button say? What if each Score had key={player}?
Show answer
Without keys it says "Bob: 3": Bob inherits Alice's score. With keys it says "Bob: 0", and switching back shows "Alice: 0" because Alice's state was thrown away when she unmounted.
- Clicks take Alice's
score0 → 1 → 2 → 3. - On Switch, the ternary returns
<Score player="Bob" />instead of<Score player="Alice" />. To React, both are "aScoreas the first child of the fragment": same type, same position. So it keeps the existing component and its state, and only updates theplayerprop. Result: "Bob: 3". - React doesn't look at variable names or at which branch of the ternary produced the element. Only the type and position in the tree (and the key) decide whether state is kept.
- With
key="Alice"andkey="Bob", the keys differ, so React unmounts Alice (destroying her state) and mounts a new Bob at0. Switching back mounts a new Alice, also at0. - This is also the trick for resetting a form when the selected item changes:
<EditForm key={user.id} user={user} />.
Learn more: Rendering lists and keys and Where state lives: a decision guide.
let renders = 0;
function Profile() {
const [name, setName] = useState("");
const [age, setAge] = useState(0);
renders++;
function load() {
setTimeout(() => {
setName("Rohit");
setAge(30);
}, 0);
}
return <button onClick={load}>{name} {age}</button>;
}How many renders do the two updates in the timeout cause in React 19? In React 17?
Show answer
One in React 18 and 19, thanks to automatic batching. Two in React 17 and earlier, which only batched inside React event handlers. The test counted exactly one extra render, showing "Rohit 30".
loadruns in a click handler, but the updates happen later, in a timer callback, outside any React event.- React 18's
createRootbatches every update that happens in the same task, wherever it comes from: timers, promises, native event listeners.setNameandsetAgeare queued together and flushed in one render. - In React 17 (
ReactDOM.render), batching only applied inside React's own event handlers. Each update in a timeout or.thenrendered immediately, so you'd get an intermediate render with "Rohit 0". - Batching is why you never see half-updated UI from two related
setStatecalls. If you really need the DOM updated in between (for example to measure it),flushSync(() => setName("Rohit"))forces a synchronous render.
Learn more: State updates are batched and React versions at a glance: 16 to 19.3.
class Counter extends Component {
state = { n: 0 };
handleClick = () => {
this.setState({ n: this.state.n + 1 });
this.setState({ n: this.state.n + 1 });
console.log("this.state.n:", this.state.n);
this.setState((prev) => ({ n: prev.n + 10 }), () => console.log("callback:", this.state.n));
};
render() {
return <button onClick={this.handleClick}>{this.state.n}</button>;
}
}Show answer
It logs this.state.n: 0, then callback: 11, and the button shows 11.
- The class version has the same batching as hooks.
this.setState({ n: this.state.n + 1 })readsthis.state.n, which is still0, so both calls queue "merge{ n: 1 }". this.stateisn't updated until React processes the queue, so theconsole.login the middle prints0.- The third call passes an updater, which receives the pending state: after the two merges that's
{ n: 1 }, so it returns{ n: 11 }. - React renders once with
n = 11, then runs thesetStatecallback (the second argument). Callbacks run after the update is committed, so it logscallback: 11. - The class-world rules: object
setStateis merged and batched, updater functions see the previous pending state, and the callback (orcomponentDidUpdate) is where you read the updated value.
Learn more: this.state and setState.