React NotesRohit’s interview study guide
Chapter 21

Review Questions

Test yourself: interview-style questions across the whole site, with worked answers.

5 topics

#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).

Predict the output: three setCount(count + 1) calls in one click
Counter.jsx
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.

What’s happening
  1. On the first render count is 0. handleClick was created during that render, so inside it count is the constant 0. It's a snapshot, not a live variable.
  2. 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.
  3. After the handler, React processes the queue: 1, then 1, then 1. One re-render, and the button shows 1.
  4. The second click runs the new render's handler, where count is 1, so it queues 2 three times and shows 2.
  5. 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.

Predict the output: mixing values and updaters
Counter.jsx
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.

What’s happening
  1. 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".
  2. Click A with count = 0: the queue is [replace with 5, c => c + 1]. Replaying from 0: 5, then 5 + 1 = 6. One render shows 6.
  3. Click B with count = 6: the queue is [c => c + 1, replace with 42]. Replaying: 7, then 42. The plain value at the end wins.
  4. 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.

Predict the output: logging state right after setting it
Counter.jsx
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.

What’s happening
  1. setCount(count + 1) doesn't change the count variable. It asks React to render again with 1. The current function call keeps its own count, which is const and equal to 0.
  2. console.log("right after:", count) runs in the same call, so it prints 0.
  3. React re-renders after the handler, and the button shows 1. That render has a new count variable equal to 1, in a new function call.
  4. The timeout callback was created by the first render's handler, so it closed over that render's count. A second later the state is 1, but the closure still sees 0. This is the "stale closure" problem.
  5. 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.

Spot the bug: the new todo doesn't appear
TodoList.jsx
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.

What’s happening
  1. todos.push(...) changes the array in place. The array now holds two items, but it's still the same object.
  2. setTodos(todos) hands React that same object. Object.is(oldTodos, newTodos) is true, so React bails out: no render, and the screen still shows one item.
  3. 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.map reads the mutated array and the "missing" item suddenly appears. Bugs like this look random.
  4. The fix is to create a new array: setTodos([...todos, "Write tests"]), or setTodos((prev) => [...prev, "Write tests"]). The new reference tells React something changed.
  5. The same rule applies to objects ({ ...user, name }) and to Redux reducers. Immer (and Redux Toolkit, which uses it) lets you write push safely because it produces a new copy behind the scenes.

Learn more: Immutability: why mutation breaks re-renders and Immer for nested updates.

Predict the output: index keys and uncontrolled inputs
List.jsx
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.

What’s happening
  1. Before the click, the rows are key 0 → Apple (its input holds "red") and key 1 → Banana. The input's value lives in the DOM, not in React state, because the input is uncontrolled.
  2. After the click, items is ["Cherry", "Apple", "Banana"], so the keys are 0 → Cherry, 1 → Apple, 2 → Banana.
  3. React matches old and new children by key. Key 0 existed 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".
  4. Key 1 gets its label changed from "Banana" to "Apple" and keeps its empty input. Key 2 is new, so a fresh Banana row with an empty input is created.
  5. 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.

Predict the output: && with an empty array
Inbox.jsx
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>.

What’s happening
  1. a && b returns a when a is falsy, without evaluating b. messages.length is 0, which is falsy, so the expression's value is 0, not false.
  2. React skips false, null, undefined and true when rendering children, but numbers are rendered, including 0. So the 0 appears as text.
  3. With two messages, 2 && <p>…</p> returns the <p>, which renders normally.
  4. Fixes: make the left side a real boolean (messages.length > 0 && …), or use a ternary (messages.length ? <p>…</p> : null). The same trap appears with count && … and price && … whenever the number can be 0.

Learn more: Conditional rendering.

Predict the output: value without onChange
Name.jsx
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."

What’s happening
  1. Passing value makes this a controlled input: React owns its value, and after every render it forces the DOM value back to the value prop.
  2. Each keystroke briefly changes the DOM value, but nothing updates name, so React restores "Rohit". From the user's side, typing does nothing.
  3. React notices value without onChange (or readOnly) and prints the warning once, on the first render.
  4. The fixes match the warning's suggestions: add onChange={(e) => setName(e.target.value)} for a controlled field, use defaultValue="Rohit" for an uncontrolled one, or add readOnly if read-only is what you meant.

Learn more: Controlled vs uncontrolled components.

Predict the output: same component, same position
Game.jsx
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.

What’s happening
  1. Clicks take Alice's score 0 → 1 → 2 → 3.
  2. On Switch, the ternary returns <Score player="Bob" /> instead of <Score player="Alice" />. To React, both are "a Score as the first child of the fragment": same type, same position. So it keeps the existing component and its state, and only updates the player prop. Result: "Bob: 3".
  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.
  4. With key="Alice" and key="Bob", the keys differ, so React unmounts Alice (destroying her state) and mounts a new Bob at 0. Switching back mounts a new Alice, also at 0.
  5. 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.

Predict the output: two updates inside a setTimeout
Profile.jsx
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".

What’s happening
  1. load runs in a click handler, but the updates happen later, in a timer callback, outside any React event.
  2. React 18's createRoot batches every update that happens in the same task, wherever it comes from: timers, promises, native event listeners. setName and setAge are queued together and flushed in one render.
  3. In React 17 (ReactDOM.render), batching only applied inside React's own event handlers. Each update in a timeout or .then rendered immediately, so you'd get an intermediate render with "Rohit 0".
  4. Batching is why you never see half-updated UI from two related setState calls. 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.

Predict the output: setState twice in a class component
Counter.jsx
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.

What’s happening
  1. The class version has the same batching as hooks. this.setState({ n: this.state.n + 1 }) reads this.state.n, which is still 0, so both calls queue "merge { n: 1 }".
  2. this.state isn't updated until React processes the queue, so the console.log in the middle prints 0.
  3. The third call passes an updater, which receives the pending state: after the two merges that's { n: 1 }, so it returns { n: 11 }.
  4. React renders once with n = 11, then runs the setState callback (the second argument). Callbacks run after the update is committed, so it logs callback: 11.
  5. The class-world rules: object setState is merged and batched, updater functions see the previous pending state, and the callback (or componentDidUpdate) is where you read the updated value.

Learn more: this.state and setState.

#Effects and refs

These questions check when effects run and clean up, what Strict Mode adds, why effects loop or go stale, and what refs do and don't do. The rule behind most of them: effects run after the commit, cleanups run before the next effect and on unmount, and changing a ref never re-renders. Every predict-the-output question was run on React 19.3.

Predict the output: render, effect and cleanup order
Logger.jsx
function Logger({ value }) {
  console.log("render", value);
  useEffect(() => {
    console.log("effect", value);
    return () => console.log("cleanup", value);
  }, [value]);
  return null;
}
// mount <Logger value={1} />, rerender with value 2, rerender with 2 again, unmount
Show answer
Output
render 1
effect 1
render 2
cleanup 1
effect 2
render 2
cleanup 2

The third render (2 again) runs no effect, and the cleanup that runs on update is the previous effect's.

What’s happening
  1. Mount: the component renders (render 1), React commits the DOM, then runs the effect (effect 1). Effects always run after the screen is updated.
  2. Rerender with 2: render runs first (render 2). The dependency changed from [1] to [2], so React runs the old effect's cleanup, which closed over value = 1 (cleanup 1), then the new effect (effect 2).
  3. Rerender with 2 again: the component renders, but [2] equals the previous [2] (compared with Object.is), so the effect and its cleanup are skipped.
  4. Unmount: React runs the last cleanup, cleanup 2. No render.
  5. Each cleanup belongs to the effect that created it and sees that render's values, which is why it prints 1 and later 2. That's what makes cleanup reliable for unsubscribing exactly what was subscribed.

Learn more: useEffect and Cleanup functions.

Predict the output: the same Logger in Strict Mode
main.jsx
render(
  <StrictMode>
    <Logger value={1} />
  </StrictMode>
);
Show answer
Output
render 1
render 1
effect 1
cleanup 1
effect 1

In development, Strict Mode renders twice and runs mount → cleanup → mount for every effect. Production does neither.

What’s happening
  1. Strict Mode calls the component function twice to surface impure renders (code that mutates something during render would behave differently the second time). Both renders log in React 19 (the test captured two render 1 lines); with React DevTools installed, the second one is shown dimmed. React 17 silenced logs during the second render, which confused people the other way.
  2. After the commit, React runs the effect (effect 1), then immediately simulates an unmount (cleanup 1) and a remount (effect 1).
  3. The point is to prove the effect cleans up properly. If the cleanup is missing, you'd see two subscriptions, two intervals, or two chat connections in development, which is the bug you'd otherwise only hit when a user navigates away and back.
  4. Don't "fix" this by tracking a hasRun ref. Make the effect symmetric: whatever it starts, its cleanup stops. A fetch with an AbortController, for example, becomes "request, abort, request" and the result is the same.

Learn more: Strict Mode runs effects twice and StrictMode.

Spot the bug: an effect that never stops
Profile.jsx
function Profile({ userId }) {
  const [user, setUser] = useState(null);
  const options = { id: userId };

  useEffect(() => {
    setUser({ ...options });
  }, [options]);

  return <p>{user?.id}</p>;
}
Show answer

It's an infinite render loop. options is a new object on every render, so the dependency always "changes", the effect runs every time, and it sets state every time. React logged: "Maximum update depth exceeded. This can happen when a component calls setState inside useEffect, but useEffect either doesn't have a dependency array, or one of the dependencies changes on every render." and kept going until the test's guard stopped it at 200 renders.

What’s happening
  1. Render 1 creates options (object A). The effect runs and calls setUser with a new object, which schedules render 2.
  2. Render 2 creates options (object B). React compares dependencies with Object.is(A, B): different objects, so the effect runs again and sets state again.
  3. This repeats forever. setUser({ ...options }) always creates a new object, so React can't bail out on "same state" either.
  4. React detects the cycle after about 50 nested updates and logs the warning, but for passive effects it doesn't stop the loop; the page keeps re-rendering.
  5. Fixes: depend on the primitive ([userId]) and build the object inside the effect, or memoise it with useMemo(() => ({ id: userId }), [userId]). Better still, if the value can be computed from props, don't use an effect at all.

Learn more: Infinite loops in effects and You might not need an effect.

Predict the output: a timer stuck at 1
Timer.jsx
function Timer() {
  const [seconds, setSeconds] = useState(0);

  useEffect(() => {
    const id = setInterval(() => setSeconds(seconds + 1), 1000);
    return () => clearInterval(id);
  }, []);

  return <p>{seconds}</p>;
}

What does it show after 5 seconds?

Show answer

1. The fixed version with setSeconds((s) => s + 1) showed 5.

What’s happening
  1. The effect runs once ([]), during the first render's commit. Its interval callback closes over that render's seconds, which is 0.
  2. Every second the callback calls setSeconds(0 + 1). The first tick changes state to 1 and re-renders.
  3. The effect doesn't re-run (empty deps), so the interval still holds the first callback, which still sees seconds = 0. Every later tick sets 1 again, React sees no change, and the display is stuck.
  4. The updater form setSeconds((s) => s + 1) doesn't read the closed-over variable. React passes in the current state each time: 0 → 1 → 2 → 3 → 4 → 5.
  5. The linter's react-hooks/exhaustive-deps rule would flag seconds as a missing dependency. Adding it would also work (the interval is recreated every second), but the updater is the cleaner fix.

Learn more: Functional updates and stale closures and The dependency array.

Spot the bug: an async effect function
Users.jsx
function Users() {
  const [users, setUsers] = useState([]);

  useEffect(async () => {
    const res = await fetch("/api/users");
    setUsers(await res.json());
  }, []);

  return <p>{users.join()}</p>;
}
Show answer

An async function always returns a promise, but an effect may only return a cleanup function or nothing. The data still loaded in the test, but React logged an error, and on unmount it tried to call the promise as a cleanup and threw TypeError: destroy is not a function (React 19.3).

What’s happening
  1. useEffect(async () => …) passes an async function. When React runs it, it returns a pending promise immediately, and React stores that as the "cleanup".
  2. React checks the return value and logs: "useEffect must not return anything besides a function, which is used for clean-up. It looks like you wrote useEffect(async () => ...) or returned a Promise. Instead, write the async function inside your effect and call it immediately".
  3. The fetch still completes and setUsers runs, so the list appears. That's why this bug survives casual testing.
  4. On unmount React calls the stored "cleanup". It's a promise, not a function, so it throws TypeError: destroy is not a function. There's also no way to cancel the request or ignore a late response.
  5. The fix is the pattern from the warning: define async function load() { … } inside the effect, call it, and return a real cleanup, ideally () => controller.abort() with an AbortController.

Learn more: Async code in effects and Race conditions and AbortController.

Predict the output: the slow request wins
Results.jsx
function Results({ query }) {
  const [results, setResults] = useState("");

  useEffect(() => {
    search(query).then((r) => setResults(r));
  }, [query]);

  return <p>{results}</p>;
}
// query changes from "a" to "ab"; the "ab" response arrives first, then the "a" response
Show answer

It ends up showing "results for a": the stale answer for the old query overwrites the right one. With an ignore flag set in the cleanup, the same sequence showed "results for ab".

What’s happening
  1. Render with "a": the effect starts request A.
  2. The query becomes "ab": React runs the old effect's cleanup (there isn't one), then the new effect starts request B.
  3. B resolves first, so setResults("results for ab"). The screen is right, for a moment.
  4. A resolves later. Its .then still calls setResults("results for a"), and the old results replace the new ones. Networks don't promise that responses arrive in order.
  5. The fix is to make each effect's cleanup cancel or ignore its own request: let ignore = false; search(query).then((r) => { if (!ignore) setResults(r); }); return () => { ignore = true; };, or pass an AbortController signal and call abort(). A data library like TanStack Query does this for you, since it keys results by query.

Learn more: Race conditions and AbortController.

Predict the output: counting clicks in a ref
ClickCounter.jsx
function ClickCounter() {
  const clicks = useRef(0);
  return (
    <button onClick={() => { clicks.current++; console.log("clicks:", clicks.current); }}>
      Clicked {clicks.current}
    </button>
  );
}

Click three times. What's logged, and what does the button say?

Show answer

It logs clicks: 1, clicks: 2, clicks: 3, but the button still says "Clicked 0".

What’s happening
  1. useRef(0) returns the same { current: 0 } object on every render. It persists across renders like state does.
  2. Each click mutates clicks.current, so the log shows the real count 1, 2, 3.
  3. Changing a ref doesn't tell React anything. No re-render happens, so the JSX that read clicks.current during the first render, 0, stays on screen.
  4. Rule: state for anything that's displayed, refs for values the UI doesn't show (timer ids, previous values, DOM nodes, "is mounted" flags). Also avoid reading or writing ref.current during render; do it in handlers and effects.

Learn more: useRef and Refs as instance variables.

Predict the output: parent and child effects, layout and passive
Parent.jsx
function Child() {
  useEffect(() => console.log("child effect"));
  useLayoutEffect(() => console.log("child layout effect"));
  console.log("child render");
  return null;
}

function Parent() {
  useEffect(() => console.log("parent effect"));
  useLayoutEffect(() => console.log("parent layout effect"));
  console.log("parent render");
  return <Child />;
}
Show answer
Output
parent render
child render
child layout effect
parent layout effect
child effect
parent effect
What’s happening
  1. Rendering goes top-down: React calls Parent, gets <Child />, then calls Child. So parent render, child render.
  2. React commits the DOM, then runs all layout effects, synchronously, before the browser paints. They run child first, because a parent's layout effect may want to measure children that are already set up.
  3. Then, after the paint, React runs all passive effects (useEffect), also child first.
  4. The order within a component doesn't matter (useEffect is written first but runs after useLayoutEffect). What matters is the phase: render, then layout effects, then passive effects.
  5. That's why useLayoutEffect is the place to measure DOM and adjust position before the user sees a flicker, while useEffect suits everything that can wait, like fetching and subscriptions.

Learn more: useLayoutEffect.

Predict the output: a ref callback with a cleanup (React 19)
Box.jsx
function Box() {
  const [n, setN] = useState(0);
  return (
    <div
      ref={(node) => {
        console.log("attach", node.tagName);
        return () => console.log("detach");
      }}
      onClick={() => setN(n + 1)}
    >
      {n}
    </div>
  );
}
// mount, click once, unmount
Show answer
Output
attach DIV        ← mount
detach            ← click: old callback's cleanup
attach DIV        ← click: new callback
detach            ← unmount

React 19 lets a ref callback return a cleanup. Because the callback is an inline function, it's a new callback on every render, so React detaches and re-attaches on each re-render.

What’s happening
  1. On mount, React calls the ref callback with the <div>, which logs attach DIV and returns a cleanup function that React stores.
  2. The click re-renders Box, which creates a brand-new arrow function for ref. React sees a different callback, so it runs the old one's cleanup (detach) and calls the new one (attach DIV).
  3. On unmount, React runs the current cleanup (detach).
  4. Before React 19 there was no cleanup return. React called the old callback with null instead, and you had to handle node === null. In React 19, when a callback returns a cleanup, React calls the cleanup and doesn't call it with null.
  5. To attach only once, give the callback a stable identity, for example useCallback(node => { … }, []). That matters when the callback does real work like starting a ResizeObserver.

Learn more: Callback refs and ref cleanup.

Predict the output: a click inside a portal
App.jsx
function App() {
  return (
    <div onClick={() => console.log("React parent onClick")}>
      {createPortal(<button>Inside portal</button>, document.body)}
    </div>
  );
}
// a native listener is also added to the element App renders into:
// container.addEventListener("click", () => console.log("DOM listener on container"))

Click the button. What's logged?

Show answer

Only React parent onClick. The native listener on the container doesn't fire, because in the DOM the button is a child of <body>, not of the container.

What’s happening
  1. createPortal(child, document.body) renders the button into <body>. The test confirmed button.parentElement.tagName is BODY.
  2. In the DOM tree, the click bubbles button → body → html → document. It never passes through the container <div>, so the native listener there doesn't run.
  3. In the React tree, though, the button is still a child of App's <div>. React's synthetic events bubble along the React tree, so the <div>'s onClick runs.
  4. This is by design: a modal rendered in a portal still behaves like part of its parent component for events and context. It can also surprise you; a click inside a portal modal can trigger an "outside click" handler or a row's onClick higher up in the React tree. Call e.stopPropagation() in the portal if you need to stop it.

Learn more: Portals.

#Hooks and performance

These questions check the rules of hooks, what actually causes a component to re-render, when memo, useMemo and useCallback help (and when they don't), and how transitions change what users see. Every predict-the-output question was run on React 19.3.

Spot the bug: a hook inside an if
Profile.jsx
function Profile({ showEmail }) {
  const [name] = useState("Rohit");
  if (showEmail) {
    const [email] = useState("rohit@example.com");
    return <p>{name} {email}</p>;
  }
  return <p>{name}</p>;
}
// render with showEmail={false}, then rerender with showEmail={true}
Show answer

The second render throws "Rendered more hooks than during the previous render." React also logs "React has detected a change in the order of Hooks called by Profile", with a table showing useState at position 2 where the previous render had nothing.

What’s happening
  1. React doesn't know hooks by name. It stores them in a list per component and matches them by call order: first useState call → slot 1, second → slot 2.
  2. First render, showEmail is false: one hook call, so the list has one slot.
  3. Second render, showEmail is true: the code calls useState twice. React finds no slot 2 from the previous render and throws, because it has no idea what state that hook should have.
  4. Going the other way (true then false) breaks too, with "Rendered fewer hooks than expected. This may be caused by an accidental early return statement."
  5. The rule: call hooks at the top level, unconditionally, in the same order every render. Put the condition inside the hook or in what you render (const [email] = useState(...) always, then {showEmail && email}). use() is the one exception that can be called conditionally.

Learn more: Rules of hooks and How hooks work under the hood.

Predict the output: React.memo with inline props
Parent.jsx
const Child = memo(function Child({ onSelect, style }) {
  console.log("Child render");
  return <p style={style} onClick={onSelect}>child</p>;
});

function Parent() {
  const [n, setN] = useState(0);
  return (
    <>
      <button onClick={() => setN(n + 1)}>{n}</button>
      <Child onSelect={() => {}} style={{ color: "red" }} />
    </>
  );
}

How many times does Child render after mounting and clicking twice? What if onSelect uses useCallback and style is a constant outside the component?

Show answer

3 times: memo didn't help at all. With a stable callback and style object it rendered once (only the mount).

What’s happening
  1. memo makes React compare the new props with the old ones, key by key with Object.is, and skip the render if they're all equal.
  2. Each Parent render creates a new arrow function for onSelect and a new object for style. They look identical but are different references, so the comparison fails and Child re-renders on every click: 1 mount + 2 = 3.
  3. With const onSelect = useCallback(() => {}, []) the function is the same reference on every render, and a style constant defined outside the component never changes. All props are now equal, so both clicks skip Child: 1 render total.
  4. That's the deal with memo: it only works when the parent keeps every prop referentially stable. One inline object or function defeats it. The React Compiler does this stabilising automatically.

Learn more: React.memo and useCallback.

Predict the output: moving state into a wrapper
Page.jsx
function ExpensiveTree() {
  console.log("ExpensiveTree render");
  return <p>tree</p>;
}

// Version 1
function Page() {
  const [colour, setColour] = useState("red");
  return (
    <div style={{ color: colour }}>
      <button onClick={() => setColour("blue")}>Blue</button>
      <ExpensiveTree />
    </div>
  );
}

// Version 2: <ColourPicker><ExpensiveTree /></ColourPicker>
function ColourPicker({ children }) {
  const [colour, setColour] = useState("red");
  return (
    <div style={{ color: colour }}>
      <button onClick={() => setColour("blue")}>Blue</button>
      {children}
    </div>
  );
}

How many times does ExpensiveTree render after one click, in each version?

Show answer

Version 1: twice (mount and the click). Version 2: once. No memo needed.

What’s happening
  1. When a component's state changes, React re-renders it and, by default, every component it creates in its JSX. In version 1, Page creates <ExpensiveTree /> on every render, so the click re-renders it too.
  2. In version 2, <ExpensiveTree /> is created by whoever renders <ColourPicker>, which didn't re-render. ColourPicker receives it as children, a prop.
  3. When ColourPicker's state changes, it re-renders and returns the same children element object it got last time. React sees an identical element and skips that subtree.
  4. That's the "lift content up" or "pass children" technique: keep fast-changing state in a small component and pass the expensive parts through as children. It often beats memo because there's nothing to keep stable.

Learn more: Why components re-render.

Predict the output: memo on a component that takes children
App.jsx
const Card = memo(function Card({ children }) {
  console.log("Card render");
  return <div>{children}</div>;
});

function App() {
  const [n, setN] = useState(0);
  return (
    <>
      <button onClick={() => setN(n + 1)}>{n}</button>
      <Card><p>Hello</p></Card>
    </>
  );
}

Mount and click twice. How many times does Card render?

Show answer

3 times. memo never skips it.

What’s happening
  1. <Card><p>Hello</p></Card> passes children as a prop. <p>Hello</p> is JSX, which compiles to a call that creates a new element object each time App renders.
  2. On each click App re-renders, creates a new <p> element, and memo compares prevProps.children === nextProps.children: different objects, so Card re-renders.
  3. So mount + 2 clicks = 3 renders, exactly as if there were no memo.
  4. Wrapping components that take JSX children in memo is almost always useless. If it really matters, memoise the children (const content = useMemo(() => <p>Hello</p>, [])) or restructure so the state lives lower down.

Learn more: React.memo.

Explain the difference: useMemo vs useCallback
React
const sorted = useMemo(() => [...items].sort(byName), [items]);
const onSelect = useCallback((id) => setSelected(id), []);
Show answer

useMemo caches the result of calling a function; useCallback caches the function itself. useCallback(fn, deps) is exactly useMemo(() => fn, deps). Both return the same reference until a dependency changes; in the test, both returned identical references across a rerender with the same deps.

What’s happening
  1. useMemo(() => [...items].sort(byName), [items]) runs the sort during render and returns the sorted array. On later renders, if items is the same reference, React returns the cached array without sorting again.
  2. useCallback((id) => setSelected(id), []) doesn't call anything. It returns the function you gave it on the first render, and keeps returning that same function.
  3. Use useMemo for expensive calculations (sorting or filtering thousands of rows) and for objects passed to memoised children or used as effect dependencies. Use useCallback for functions passed to memoised children or used as dependencies.
  4. Neither is free: they cost memory and a dependency comparison every render, and they make code harder to read. Without a memo child or a dependency array that needs a stable reference, useCallback does nothing useful.
  5. With the React Compiler enabled, it inserts this memoisation for you at build time, so in new code you rarely write either by hand.

Learn more: useMemo, useCallback and The React Compiler.

Predict the output: two components, one custom hook
Toggles.jsx
function useToggle() {
  const [on, setOn] = useState(false);
  return [on, () => setOn((o) => !o)];
}

function A() {
  const [on, toggle] = useToggle();
  return <button onClick={toggle}>A is {on ? "on" : "off"}</button>;
}

function B() {
  const [on] = useToggle();
  return <p>B is {on ? "on" : "off"}</p>;
}
// <A /><B />, then click A's button
Show answer

"A is on", "B is off". Custom hooks share logic, not state.

What’s happening
  1. A custom hook is just a function that calls other hooks. Calling useToggle() inside A puts a useState in A's hook list; calling it inside B puts a separate useState in B's.
  2. Clicking A calls A's setOn, which only updates A's state. B's state is untouched, so B still says "off".
  3. Each call to a custom hook gets its own state, like calling useState twice. The use prefix is a naming convention that lets the linter apply the rules of hooks; it doesn't create anything shared.
  4. To share state between components, lift it into a common parent, put it in context, or use an external store (Redux, Zustand) that both read with a hook.

Learn more: Custom hooks.

Predict the output: typing into a debounced search
DebouncedSearch.jsx
function DebouncedSearch({ onSearch, delay = 300 }) {
  const [text, setText] = useState("");

  useEffect(() => {
    if (!text) return;
    const id = setTimeout(() => onSearch(text), delay);
    return () => clearTimeout(id);
  }, [text, delay, onSearch]);

  return <input aria-label="Search" value={text} onChange={(e) => setText(e.target.value)} />;
}
// the user types "abc" quickly, then waits 300 ms

How many times is onSearch called, and with what?

Show answer

Once, with "abc". The test recorded [["abc"]].

What’s happening
  1. Typing a sets text to "a". After the render, the effect schedules onSearch("a") for 300 ms later.
  2. Typing b quickly changes text to "ab". Before the new effect runs, React runs the previous effect's cleanup, clearTimeout, which cancels the "a" search. The new effect schedules "ab".
  3. c does the same: cancels "ab", schedules "abc".
  4. The user stops. 300 ms later the last timer fires: onSearch("abc"). Three keystrokes, one request.
  5. The cleanup is what makes this a debounce. Without it, all three timers would fire and you'd get three searches. Note that onSearch is a dependency: if the parent passes a new function every render, every re-render restarts the timer, so the parent should pass a stable callback (useCallback) or the hook should read it through useEffectEvent.

Learn more: useDebounce and useThrottle and Debouncing and throttling in components.

Explain this: what does the React Compiler change?
React
// You write:
function ProductList({ products, filter }) {
  const visible = products.filter((p) => p.name.includes(filter));
  return <List items={visible} onSelect={(id) => track(id)} />;
}
Show answer

The React Compiler (1.0 since October 2025, a build-time Babel plugin that Vite, Next.js and other tools can run) analyses components at build time and inserts memoisation automatically: the filtered array and the onSelect function get cached and are only recreated when products or filter change, so a memoised List really does skip re-renders. You write plain code; it behaves as if you'd used useMemo, useCallback and memo correctly everywhere.

What’s happening
  1. Without the compiler, every render of ProductList creates a new visible array and a new onSelect function, so List re-renders even if it's wrapped in memo.
  2. The compiler sees that visible depends only on products and filter, and onSelect depends on nothing that changes. It rewrites the component to keep those values in a cache slot and reuse them while their inputs are the same.
  3. It can memoise more finely than people do by hand: individual JSX elements, values computed after an early return, and so on, without dependency arrays you can get wrong.
  4. It relies on the rules of React: render must be pure, props and state are treated as immutable, hooks are called unconditionally. Code that mutates props or reads refs during render can't be optimised safely, so the compiler skips those components (the ESLint plugin reports why).
  5. Interview answer: "With the compiler, useMemo and useCallback become an escape hatch rather than routine. I still understand them, for codebases without the compiler and for effect dependencies where I need exact control."

Learn more: The React Compiler.

Predict the output: Suspense with and without a transition
App.jsx
function Album({ id }) {
  const album = use(fetchAlbum(id)); // cached promise per id
  return <h2>{album.title}</h2>;
}

function App() {
  const [id, setId] = useState(1);
  const [isPending, startTransition] = useTransition();
  return (
    <>
      <button onClick={() => setId(id + 1)}>Next</button>
      <button onClick={() => startTransition(() => setId(id + 1))}>Next (transition)</button>
      {isPending && <span>Switching…</span>}
      <Suspense fallback={<p>Loading…</p>}>
        <Album id={id} />
      </Suspense>
    </>
  );
}

Album 1 is showing. What's on screen right after clicking "Next" while album 2 loads? And after "Next (transition)" while album 3 loads?

Show answer

After Next: the fallback "Loading…". Album 1 is still in the DOM but hidden (React set display: none !important on it). After Next (transition): album 2 stays visible with "Switching…" next to it, and album 3 replaces it only when its data is ready.

What’s happening
  1. Clicking Next is an urgent update. Album renders with id = 2, use() finds an unresolved promise and suspends. React must show something for the new state, so it shows the nearest Suspense fallback and hides the old content (keeping its state, so it can come back).
  2. When album 2's promise resolves, React retries the render and swaps the fallback for "Album 2".
  3. Clicking "Next (transition)" marks the update as non-urgent. When Album suspends during a transition, React doesn't replace already-visible content with a fallback. It keeps showing album 2 and sets isPending to true, so "Switching…" appears.
  4. When album 3's data arrives, React commits the new tree and isPending goes back to false.
  5. That's the practical use of transitions: tab switches and navigation that keep the old screen (with a subtle pending indicator) instead of flashing a spinner. Routers and data libraries wrap navigations in transitions for this reason. Testing this needed await act(async () => …) around the render and each click, because suspending components don't work with a synchronous act in React 19.

Learn more: useTransition and Suspense boundaries.

Explain the difference: useTransition vs useDeferredValue
React
const [isPending, startTransition] = useTransition();
startTransition(() => setTab("photos"));

const deferredQuery = useDeferredValue(query);
<SlowResults query={deferredQuery} />
Show answer

Both mark work as non-urgent so typing and clicking stay responsive. useTransition wraps the state update (you own the setter); useDeferredValue wraps a value (useful when you receive it as a prop or can't wrap the update).

What’s happening
  1. startTransition(() => setTab("photos")) tells React the update can be interrupted. If the user clicks something else while the photos tab renders, React abandons that render and handles the click first. isPending lets you show that work is in progress.
  2. useDeferredValue(query) returns the old value during urgent renders and schedules a background render with the new one. The input shows each keystroke immediately; SlowResults gets deferredQuery and catches up when React has time.
  3. During the background render, query !== deferredQuery, which you can use to dim stale results: style={{ opacity: query !== deferredQuery ? 0.5 : 1 }}.
  4. Neither debounces or skips network requests; they schedule rendering. If the slow part is a request, debounce it or use a data library.
  5. Both are built on concurrent rendering, which is also why external stores need useSyncExternalStore: during an interruptible render, a store that changes mid-render could otherwise show two different values in one screen ("tearing").

Learn more: useTransition, useDeferredValue and Concurrent features and tearing.

#State management and data

These questions check how context, Redux, Zustand and TanStack Query decide who re-renders and when data is fetched: reference equality, selectors, immutability, and cache keys. Every predict-the-output question was run on React 19.3 with Redux Toolkit 2, react-redux 9, Zustand 5 and TanStack Query 5.

Predict the output: a memoised context consumer
App.jsx
const UserContext = createContext(null);

const Avatar = memo(function Avatar() {
  const { user } = useContext(UserContext);
  console.log("Avatar render");
  return <p>{user.name}</p>;
});

function App() {
  const [count, setCount] = useState(0);
  const [user] = useState({ name: "Rohit" });
  return (
    <UserContext value={{ user }}>
      <button onClick={() => setCount(count + 1)}>{count}</button>
      <Avatar />
    </UserContext>
  );
}

Mount and click twice. How many times does Avatar render? And with const value = useMemo(() => ({ user }), [user])?

Show answer

3 times with the inline object. Once with the memoised value. (<UserContext value> is the React 19 provider syntax; in React 18 it's <UserContext.Provider value>.)

What’s happening
  1. memo protects Avatar from re-rendering because its parent re-rendered: it has no props, so that check always passes. Context is a separate channel.
  2. Every component that calls useContext(UserContext) re-renders when the provider's value changes, compared with Object.is, regardless of memo.
  3. value={{ user }} creates a new object on every App render. The click changes count, App re-renders, the value is a new object, and every consumer re-renders, even though user is the same: 1 + 2 = 3.
  4. useMemo(() => ({ user }), [user]) returns the same object while user is unchanged, so the provider's value is stable and Avatar skips both clicks: 1 render.
  5. The bigger lesson: put values that change at different rates in different contexts, and memoise provider values. Context isn't a state manager with selectors; every consumer gets every change.

Learn more: Context best practices and re-renders.

Predict the output: default value vs a provider with undefined
Labels.jsx
const ThemeContext = createContext("light");

function Label() {
  return <span>[{String(useContext(ThemeContext))}]</span>;
}

<>
  <Label />
  <ThemeContext value="dark"><Label /></ThemeContext>
  <ThemeContext value={undefined}><Label /></ThemeContext>
</>
Show answer

[light][dark][undefined]. The default is used only when there's no provider at all above the component.

What’s happening
  1. The first Label has no ThemeContext provider above it, so useContext returns the default passed to createContext: "light".
  2. The second is inside a provider with value="dark", so it reads "dark".
  3. The third is inside a provider whose value is undefined. A provider is found, so React returns its value, undefined. It doesn't fall back to the default.
  4. This catches people who write <ThemeContext value={props.theme}> where theme is sometimes missing. Handle the fallback at the provider (value={props.theme ?? "light"}), or throw from a custom useTheme() hook when the value is missing.

Learn more: The Context API and useContext.

Spot the bug: a classic Redux reducer that pushes
store.js
function todosReducer(state = { items: [] }, action) {
  switch (action.type) {
    case "todos/added":
      state.items.push(action.payload);
      return state;
    default:
      return state;
  }
}

const store = createStore(todosReducer); // plain Redux, no Toolkit
// a component shows useSelector((s) => s.items).length
Show answer

The reducer mutates state and returns the same object, so react-redux thinks nothing changed. In the test, after dispatching todos/added, the store held 1 item but the component still showed "0 todos".

What’s happening
  1. state.items.push(...) changes the existing array in place, and return state hands back the same root object.
  2. After every dispatch, react-redux runs each component's selector and compares the result with the previous one by reference. s.items is the same array as before, so the component doesn't re-render.
  3. The store's data really changed (getState().items.length is 1), but the UI is stale. Redux DevTools' time travel breaks too, because every history entry points at the same mutated object.
  4. The classic fix is to return new objects: return { ...state, items: [...state.items, action.payload] };. With Redux Toolkit's createSlice, state.items.push(action.payload) is correct, because Immer turns it into an immutable update.
  5. Plain createStore from redux still works in Redux 5 but is marked deprecated (and exported as legacy_createStore) to push you towards Toolkit's configureStore, which also includes a mutation check that throws on mistakes like this in development.

Learn more: Classic Redux with react-redux and Redux core concepts.

Predict the output: mutate and return in createSlice
todosSlice.js
const slice = createSlice({
  name: "todos",
  initialState: [],
  reducers: {
    added(state, action) {
      state.push(action.payload);
      return [...state];
    },
  },
});
Show answer

Dispatching added throws: "[Immer] An immer producer returned a new value and modified its draft. Either return a new value or modify the draft."

What’s happening
  1. In createSlice, state is an Immer draft: a proxy that records your changes. When the reducer returns undefined, Immer builds the next state from the recorded changes.
  2. Alternatively you may return a brand-new value, and Immer uses that and ignores the draft.
  3. This reducer does both: it pushes (modifying the draft) and returns a new array. Immer can't tell which one you meant, so it throws instead of guessing.
  4. Pick one style per reducer: state.push(action.payload) with no return, or return [...state, action.payload] without touching the draft. Returning is handy for replacing state wholesale, e.g. reset: () => initialState.
  5. A common trap is an arrow function with an expression body: added: (state, action) => state.push(action.payload) returns the new length from push, so it also mutates and returns. Use braces.

Learn more: Redux Toolkit: createSlice and configureStore and Immer for nested updates.

Predict the output: a selector that returns a new object
Name.jsx
function Name() {
  const { name, age } = useSelector((s) => ({ name: s.name, age: s.age }));
  return <p>{name} {age}</p>;
}
// dispatch an unrelated action (it changes s.clicks) twice

How many times does Name render? Does react-redux say anything?

Show answer

3 times: the mount plus once for each unrelated dispatch. And react-redux 9 warns in development: "Selector unknown returned a different result when called with the same parameters. This can lead to unnecessary rerenders. Selectors that return a new reference (such as an object or an array) should be memoized".

What’s happening
  1. After every dispatch, useSelector runs the selector and compares the result with the last one using ===.
  2. The selector builds a new object each time, so the result is never === the previous one, even when name and age are unchanged. Every dispatch, to any part of the store, re-renders Name.
  3. react-redux's development check runs the selector twice with the same state, sees two different references, and prints the warning (named "unknown" because the selector is anonymous).
  4. Fixes: select primitives separately (const name = useSelector((s) => s.name)), pass shallowEqual as the second argument, or memoise derived data with createSelector from Redux Toolkit.

Learn more: Classic Redux with react-redux and Choosing a state management approach.

Predict the output: the order of a thunk
thunk-demo.js
const store = createStore(reducer, applyMiddleware(thunk)); // reducer logs "reducer: <type>"

const fetchUser = () => async (dispatch, getState) => {
  console.log("thunk starts, status:", getState().status);
  dispatch({ type: "user/loading" });
  const user = await api.getUser();
  dispatch({ type: "user/loaded", user });
};

const returned = store.dispatch(fetchUser());
console.log("after dispatch, status:", store.getState().status, "returned a Promise:", returned instanceof Promise);
await returned;
console.log("after await, status:", store.getState().status);
Show answer
Output
thunk starts, status: idle
reducer: loading
after dispatch, status: loading returned a Promise: true
reducer: loaded
after await, status: done
What’s happening
  1. store.dispatch(fetchUser()) passes a function, not an action. The thunk middleware sees a function and calls it with (dispatch, getState) instead of passing it to the reducer.
  2. The thunk runs synchronously up to its first await: it logs, then dispatches user/loading, which goes through the reducer straight away.
  3. At await the async function pauses and returns a promise, which the middleware returns from dispatch. So the line after dispatch runs next, and sees status: loading.
  4. When the API call resolves, the thunk resumes and dispatches user/loaded. Awaiting the returned promise lets the caller know the whole operation finished.
  5. That's all Redux Thunk is (the part my notes found confusing): middleware that lets dispatch accept a function, so async logic can dispatch real actions before and after its awaits. createAsyncThunk generates exactly this pattern with pending/fulfilled/rejected actions.

Learn more: Redux Thunk.

Predict the output: createAsyncThunk actions and unwrap
users.js
const fetchUser = createAsyncThunk("users/fetch", async (id) => {
  if (id < 0) throw new Error("bad id");
  return { id, name: "Asha" };
});

const ok = await store.dispatch(fetchUser(1));
const failed = await store.dispatch(fetchUser(-1));
await store.dispatch(fetchUser(-1)).unwrap();

Which action types are dispatched, what are ok and failed, and what does the last line do?

Show answer

The types are users/fetch/pending then users/fetch/fulfilled for id 1, and pending then rejected for each id -1. ok is the fulfilled action (payload: { id: 1, name: "Asha" }), failed is the rejected action, not an exception (failed.error.message is "bad id"). Only the .unwrap() line throws, with the serialized error { name: "Error", message: "bad id", stack }.

What’s happening
  1. createAsyncThunk("users/fetch", payloadCreator) creates a thunk plus three action creators named from the prefix: users/fetch/pending, /fulfilled and /rejected.
  2. Dispatching it first dispatches pending, then calls your function. A returned value becomes the payload of fulfilled; a thrown error becomes rejected, with the error serialized into action.error (a plain object, since Redux state and actions should be serializable).
  3. await dispatch(thunk) resolves to the final action in both cases. A failed request doesn't reject the promise, so try/catch around a plain await dispatch(...) never catches anything.
  4. .unwrap() turns it back into normal promise behaviour: it resolves with the payload or throws the serialized error. Use it in components when you need to react to the outcome ("close the dialog on success, show the message on failure").
  5. Reducers handle the three actions in extraReducers (builder.addCase(fetchUser.pending, …)) to track status and error.

Learn more: createAsyncThunk.

Predict the output: three components, two query keys
Todos.jsx
const fetchTodos = vi.fn(async () => ["a", "b"]);
const useTodos = (key) => useQuery({ queryKey: key, queryFn: fetchTodos });

function Count() { const { data } = useTodos(["todos"]); /* … */ }
function List()  { const { data } = useTodos(["todos"]); /* … */ }
function Done()  { const { data } = useTodos(["todos", { done: true }]); /* … */ }
// <Count /><List /><Done /> under one QueryClientProvider

How many times is fetchTodos called?

Show answer

Twice: once for ["todos"] and once for ["todos", { done: true }]. Count and List share one request and one cache entry.

What’s happening
  1. TanStack Query stores data in a cache keyed by the query key. Keys are compared by value (hashed deterministically), not by reference, so two separate ["todos"] arrays are the same key.
  2. Count mounts first and starts the fetch for ["todos"]. When List mounts with the same key, the query is already in flight, so it subscribes to the same request instead of starting another. That's deduplication.
  3. ["todos", { done: true }] is a different key, so Done gets its own cache entry and its own fetch.
  4. This is why my notes called query keys "really important": the key is the identity of the data. Put everything the query function depends on (ids, filters, page) in the key, or two different requests will share one cache entry.
  5. Keys are hierarchical, so queryClient.invalidateQueries({ queryKey: ["todos"] }) invalidates both entries, which is how a mutation refreshes every list of todos at once.

Learn more: Query keys and TanStack Query: useQuery.

Predict the output: remounting with staleTime 0 vs 60 seconds
User.jsx
function User() {
  const { data } = useQuery({ queryKey: ["user"], queryFn: fetchUser, staleTime });
  return <p>{data ? data.name : "loading"}</p>;
}
// mount, wait for data, unmount, mount again

What does the remounted User show first, and how many times is fetchUser called with staleTime: 0 (the default) and with staleTime: 60_000?

Show answer

Both show "Asha" immediately from the cache. With staleTime: 0, fetchUser is called twice (a background refetch on remount). With 60 seconds it's called once.

What’s happening
  1. The first mount fetches and caches the data. Unmounting doesn't delete it: the cache entry becomes inactive and is kept for gcTime (5 minutes by default).
  2. Remounting finds cached data, so data is available on the very first render: no loading state, the name appears instantly.
  3. With the default staleTime: 0, data is considered stale as soon as it arrives. A new observer mounting on stale data triggers a background refetch (refetchOnMount), so fetchUser runs a second time while the old data stays on screen.
  4. With staleTime: 60_000, the data is fresh for a minute, so remounting just reads the cache: one fetch total.
  5. That's "stale-while-revalidate": show what you have, refresh quietly. staleTime controls when to refetch, gcTime controls how long to keep unused data.

Learn more: staleTime, gcTime and refetching.

Predict the output: Zustand selectors
store.js
const useStore = create((set) => ({
  count: 0,
  name: "Rohit",
  rename: (name) => set({ name }),
  increment: () => set((s) => ({ count: s.count + 1 })),
}));

function CountLabel() { const count = useStore((s) => s.count); /* … */ }
function WholeStore() { const s = useStore(); /* … */ }
// rename("Asha"), rename("Ravi"), increment()

How many times does each component render, counting the mount?

Show answer

CountLabel renders 2 times (mount + increment). WholeStore renders 4 times (mount + all three updates).

What’s happening
  1. A Zustand store lives outside React. Components subscribe with the hook, and on every set Zustand re-runs each subscriber's selector and compares the result with Object.is.
  2. CountLabel selects s.count. The two renames don't change count, so the selector returns the same number and nothing happens. increment changes it, so one re-render.
  3. WholeStore calls useStore() with no selector, which selects the whole state object. Every set produces a new state object, so it re-renders on every update.
  4. Like useSelector, select the smallest piece you need. Selecting several fields as a new object ((s) => ({ a: s.a, b: s.b })) re-renders every time unless you wrap the selector with Zustand's useShallow.

Learn more: Zustand.

Explain this: Context, Redux, Zustand or TanStack Query?

An interviewer asks: "We use Redux for everything, including API data. Is that right?"

Show answer

Split state by kind. Server state (data that lives on the backend: users, orders, search results) belongs in a server-state cache like TanStack Query or RTK Query, which handles caching, deduplication, refetching and invalidation. Client state (UI state the server doesn't know about) belongs in useState where possible, context for rarely-changing app-wide values, and Redux Toolkit or Zustand for complex, frequently-updated shared state.

What’s happening
  1. Putting API data in Redux by hand means writing loading flags, error handling, caching, refetch-on-focus and cache invalidation yourself, for every endpoint. That's most of the "Redux is boilerplate" complaint, and TanStack Query or RTK Query make it a few lines.
  2. What's left after moving server state out is usually small: the open modal, a multi-step form, the selected filter, a shopping cart before checkout. Much of it can be local useState or useReducer.
  3. Context suits values that change rarely and are read widely: theme, locale, the signed-in user. It re-renders every consumer on each change, so it's a poor fit for fast-changing state.
  4. Redux Toolkit suits large, shared client state with complex update logic, strict conventions and DevTools time travel. Zustand suits the same need with far less ceremony and selector-based subscriptions.
  5. Interview answer: "Redux for everything was the 2018 default. Today I'd move API data to TanStack Query or RTK Query, keep local state local, use context for app-wide settings, and keep Redux or Zustand for the genuinely shared client state that's left."

Learn more: Server state vs client state and Choosing a state management approach.

#Architecture and React 19

These questions cover React 19's actions and removed APIs, error boundaries, component patterns, routing, server rendering and Server Components, testing and micro-frontends. Predict-the-output questions were run on React 19.3 with React Router 8 and react-dom/server. Next.js and Module Federation can't run in the sandbox, so those are explain-the-difference questions.

Predict the output: useActionState with a validation error
AddForm.jsx
async function addToCart(prevState, formData) {
  const qty = Number(formData.get("qty"));
  await saveToServer(qty);
  if (qty > 5) return { ...prevState, error: "You can add at most 5" };
  return { error: null, total: prevState.total + qty };
}

function AddForm() {
  const [state, formAction, isPending] = useActionState(addToCart, { error: null, total: 0 });
  return (
    <form action={formAction}>
      <input aria-label="Quantity" name="qty" defaultValue="1" />
      <button>{isPending ? "Adding…" : "Add"}</button>
      <p>Total: {state.total}</p>
      {state.error && <p role="alert">{state.error}</p>}
    </form>
  );
}

The user changes the quantity to 2 and submits, then to 9 and submits. What happens?

Show answer

While the first submit runs, the button says "Adding…". Then "Total: 2", and the input resets to its default "1". After submitting 9, the alert says "You can add at most 5", the total stays 2, and the input resets to "1" again.

What’s happening
  1. useActionState(action, initialState) returns the current state, a formAction to pass to <form action>, and isPending. Submitting calls addToCart(previousState, formData) inside a transition.
  2. While the async action runs, isPending is true, so the button shows "Adding…". No useState for loading, no e.preventDefault().
  3. Whatever the action returns becomes the new state. First submit: { error: null, total: 0 + 2 } → "Total: 2".
  4. Second submit: qty is 9, so the action returns the previous total with an error. Returning state (instead of throwing) is how validation errors reach the UI.
  5. The surprise is the input. In React 19, after a <form action> finishes, React resets the form, so uncontrolled inputs go back to their defaultValue, even after the error. To keep what the user typed, return it in the state and use it as defaultValue, or make the input controlled.

Learn more: useActionState and Form actions and useFormStatus.

Predict the output: useOptimistic when the save fails
LikeButton.jsx
function LikeButton() {
  const [likes, setLikes] = useState(10);
  const [optimisticLikes, addOptimistic] = useOptimistic(likes, (current, delta) => current + delta);

  function like() {
    startTransition(async () => {
      addOptimistic(1);
      try {
        await saveLike();
        startTransition(() => setLikes((l) => l + 1));
      } catch {
        console.log("save failed");
      }
    });
  }

  return <button onClick={like}>♥ {optimisticLikes}</button>;
}

What does the button show while saving, and afterwards, when saveLike succeeds and when it fails?

Show answer

While saving it shows ♥ 11 either way. On success it stays at 11; on failure it goes back to ♥ 10 (and logs "save failed"). No rollback code was written.

What’s happening
  1. useOptimistic(likes, reducer) returns a value that equals likes, except while a transition is running, when it also applies the optimistic updates you've added.
  2. addOptimistic(1) inside the transition makes optimisticLikes 10 + 1 = 11 immediately, so the click feels instant.
  3. On success, setLikes updates the real state to 11 inside a transition. When the action finishes, React drops the optimistic layer and shows the real likes: 11.
  4. On failure, likes is still 10. When the transition ends, the optimistic update is discarded and the button shows the real value again: 10. The "rollback" is automatic because optimistic values only exist while the action is pending.
  5. TanStack Query's optimistic updates do the same job for server cache data, with explicit onMutate and onError rollback. useOptimistic is the built-in version for state you own.

Learn more: useOptimistic and Optimistic updates.

Spot the bug: use() with a promise created in render
User.jsx
function User() {
  const user = use(fetch("/api/user").then((r) => r.json()));
  return <h1>{user.name}</h1>;
}
// <Suspense fallback={<p>Loading…</p>}><User /></Suspense>
Show answer

Every render creates a new promise, so use() can never recognise "the" request. React 19.3 warns: "A component was suspended by an uncached promise. Creating promises inside a Client Component or hook is not yet supported, except via a Suspense-compatible library or framework." In the test, fetch was called 3 times for one component.

What’s happening
  1. use(promise) suspends the component until the promise settles, then React renders it again and expects use to get the same promise back, now resolved, so it can return the value.
  2. Here the render creates a fresh fetch(...) promise every time. Each retry starts a new request and suspends on it again, so you get extra requests at best and a suspend loop at worst.
  3. React detects the uncached promise and warns. It doesn't throw, which is why this can look like it "works" during development.
  4. The promise must come from somewhere that outlives the render: created in a Server Component and passed down as a prop, stored in a cache keyed by id, or provided by a Suspense-aware library (TanStack Query's useSuspenseQuery, a router loader).
  5. use() itself is fine with conditions and loops (unlike other hooks), and it also reads context: use(ThemeContext) works inside an if.

Learn more: use() and use() with Suspense for data.

Predict the output: defaultProps and propTypes in React 19
Greeting.jsx
function Greeting({ name }) {
  return <p>Hello, {name}!</p>;
}
Greeting.defaultProps = { name: "Guest" };
Greeting.propTypes = { name: PropTypes.string.isRequired };

function Modern({ name = "Guest" }) {
  return <p>Hi, {name}!</p>;
}

class OldClass extends Component {
  static defaultProps = { name: "Guest" };
  render() { return <p>Class: {this.props.name}</p>; }
}

<><Greeting /><Greeting name={42} /><Modern /><OldClass /></>
Show answer

Hello, ! Hello, 42! Hi, Guest! Class: Guest, and no warnings at all.

What’s happening
  1. React 19 removed defaultProps for function components. Greeting.defaultProps is ignored, so name is undefined and the first greeting renders "Hello, !".
  2. React 19 also removed propTypes checking. name={42} breaks the declared PropTypes.string, and the first Greeting is missing a required prop, but nothing is logged. The prop-types package still exists; React just doesn't call it anymore.
  3. Modern uses a default parameter, the replacement for function components, so it shows "Guest".
  4. Class components still support defaultProps in React 19, so OldClass shows "Class: Guest".
  5. When upgrading an older codebase: convert defaultProps on function components to default parameters (a codemod does it), and replace propTypes with TypeScript, or runtime validation such as Zod where data comes from outside.

Learn more: Default values: default parameters vs defaultProps, PropTypes: runtime prop checking and Removed and deprecated APIs.

Predict the output: ReactDOM.render in React 19
legacy.jsx
import * as ReactDOM from "react-dom";

console.log(typeof ReactDOM.render, typeof ReactDOM.hydrate,
  typeof ReactDOM.unmountComponentAtNode, typeof ReactDOM.findDOMNode);
console.log(typeof ReactDOM.createPortal, typeof ReactDOM.flushSync);
ReactDOM.render(<App />, document.getElementById("root"));
Show answer
Output
undefined undefined undefined undefined
function function
TypeError: … render is not a function

React 19 removed ReactDOM.render, hydrate, unmountComponentAtNode and findDOMNode. createPortal and flushSync are still in react-dom.

What’s happening
  1. ReactDOM.render was the React 17-and-earlier way to start an app. React 18 deprecated it in favour of createRoot from react-dom/client (it still worked but ran in legacy, non-concurrent mode).
  2. React 19 deleted it, along with hydrate (now hydrateRoot), unmountComponentAtNode (now root.unmount()) and findDOMNode (use a ref). So they're simply undefined on the module.
  3. Calling undefined throws a TypeError. (In Vitest the message shows Vite's rewritten import name instead of ReactDOM, but it's the same error.)
  4. The modern entry point: createRoot(document.getElementById("root")).render(<App />). The upgrade also turns on automatic batching and concurrent features.

Learn more: Rendering an app: createRoot vs ReactDOM.render and Removed and deprecated APIs.

Predict the output: errors in a handler vs in render
Bomb.jsx
function Bomb() {
  const [explode, setExplode] = useState(false);
  if (explode) throw new Error("render boom");
  return (
    <>
      <button onClick={() => { throw new Error("click boom"); }}>Throw in handler</button>
      <button onClick={() => setExplode(true)}>Throw in render</button>
    </>
  );
}

<ErrorBoundary fallback={<p role="alert">Something went wrong</p>}>
  <Bomb />
</ErrorBoundary>

Click "Throw in handler", then "Throw in render". When does the fallback appear?

Show answer

Only after "Throw in render". The handler error doesn't reach the boundary: the buttons stay on screen and the error surfaces as an uncaught error (a window error event in the test).

What’s happening
  1. Error boundaries catch errors thrown while React is rendering, in lifecycle methods and in effects of the components below them. They work because React controls those calls and can unwind to the nearest boundary.
  2. An event handler runs later, outside rendering, called by the browser's event dispatch. When it throws, React isn't in the middle of rendering anything, so there's nothing to unwind. The error goes to the global handler (window.onerror, the console).
  3. "Throw in render" sets state; on the next render Bomb throws during rendering, React discards that subtree and renders the boundary's fallback.
  4. To show a handler error in the UI, catch it and put it in state (try { … } catch (e) { setError(e) }), or rethrow it during render. react-error-boundary's showBoundary(error) from useErrorBoundary() does exactly that.
  5. Boundaries also don't catch errors in async code (setTimeout, promise callbacks), in server rendering, or thrown in the boundary itself.

Learn more: What error boundaries don't catch and react-error-boundary.

Spot the bug: creating a HOC during render
Page.jsx
function withBorder(Wrapped) {
  return function Bordered(props) {
    return <div className="border"><Wrapped {...props} /></div>;
  };
}

function Page() {
  const [n, setN] = useState(0);
  const BorderedInput = withBorder(NameInput); // NameInput renders <input aria-label="Name" />
  return (
    <>
      <button onClick={() => setN(n + 1)}>Re-render ({n})</button>
      <BorderedInput />
    </>
  );
}

The user types "Rohit", then clicks the button. What's in the input?

Show answer

Nothing: it's empty. withBorder(NameInput) returns a new component type on every render, so React unmounts the old input and mounts a fresh one. With const BorderedInput = withBorder(NameInput) moved outside Page, the input kept "Rohit".

What’s happening
  1. withBorder is a higher-order component: a function that takes a component and returns a new one. Each call returns a new Bordered function.
  2. Calling it inside Page means every render produces a different BorderedInput function. To React, the element at that position now has a different type.
  3. When the type changes, React doesn't update; it unmounts the old subtree (destroying its DOM and state) and mounts a new one. The typed text was in the old <input>, so it's gone, and focus is lost too.
  4. The same bug happens with a component defined inside another component (function Row() {…} inside Table). Define components and apply HOCs at module level, once.
  5. This is one reason hooks replaced most HOCs: a hook can't accidentally create a new component type.

Learn more: Higher-order components and HOCs vs render props vs hooks.

Predict the output: a nested route without an Outlet
App.jsx
function Dashboard() {
  return <h1>Dashboard</h1>;
}

<MemoryRouter initialEntries={["/dashboard/settings"]}>
  <Routes>
    <Route path="dashboard" element={<Dashboard />}>
      <Route path="settings" element={<h2>Settings</h2>} />
    </Route>
  </Routes>
</MemoryRouter>

What renders at /dashboard/settings?

Show answer

Only <h1>Dashboard</h1>. The child route matches but has nowhere to render. Adding <Outlet /> to Dashboard rendered <h1>Dashboard</h1><h2>Settings</h2>.

What’s happening
  1. Nested <Route>s create nested layouts. For /dashboard/settings, React Router matches both routes: the parent dashboard and its child settings.
  2. It renders the parent's element, and the child's element is handed to the parent to place wherever it renders <Outlet />.
  3. Dashboard has no <Outlet />, so the matched child is silently dropped. There's no error or warning, which makes this a common "my route doesn't work" bug.
  4. With <Outlet />, the child renders inside the layout. That's how a shared sidebar or header stays mounted while the inner page changes.

Learn more: Nested routes and Outlet.

Predict the output: renderToString and effects
Clock.jsx
function Clock() {
  const [time, setTime] = useState("not set");
  console.log("render, time =", time);
  useEffect(() => {
    console.log("effect runs");
    setTime("12:00");
  });
  return <p>Time: {time}</p>;
}

const html = renderToString(<Clock />);

What's in html, and what's logged?

Show answer

html is <p>Time: <!-- -->not set</p>, and the only log is render, time = not set. The effect never runs on the server.

What’s happening
  1. renderToString from react-dom/server calls the component once to produce HTML. There's no DOM and no commit phase on the server, so effects (useEffect, useLayoutEffect) are skipped entirely.
  2. So setTime("12:00") never happens, and the HTML contains the initial state, "not set". useState initialisers do run, because they're part of rendering.
  3. The <!-- --> comment separates two adjacent text nodes ("Time: " and the value), so hydration on the client can match them up exactly.
  4. On the client, hydrateRoot attaches to this HTML, and only then does the effect run and update the time to "12:00". That's why browser-only code (window, localStorage, measuring) belongs in effects: it's guaranteed not to run on the server.
  5. renderToString is the oldest server API and doesn't support streaming or waiting for Suspense data; modern setups use renderToPipeableStream or renderToReadableStream, usually through a framework.

Learn more: Server rendering with react-dom/server and Hydration.

Predict the output: a hydration mismatch
hydrate-demo.jsx
function Stamp({ where }) {
  return <p>Rendered on the {where}</p>;
}

container.innerHTML = renderToString(<Stamp where="server" />);
hydrateRoot(container, <Stamp where="client" />, {
  onRecoverableError: (error) => console.log(error.message),
});

What text ends up on the page, and what's reported?

Show answer

The page shows "Rendered on the client", and onRecoverableError receives: "Hydration failed because the server rendered text didn't match the client. As a result this tree will be regenerated on the client. This can happen if a SSR-ed Client Component used: …" (followed by a list of common causes).

What’s happening
  1. Hydration expects the client's first render to produce exactly the same output as the server's HTML. React walks the existing DOM and attaches event handlers instead of creating new nodes.
  2. Here the server said "server" and the client renders "client". React detects the text mismatch during hydration.
  3. React 19 doesn't patch individual text nodes. It throws away the server HTML for that tree and re-renders it on the client, so the final text is the client's. That costs performance and can flash.
  4. The error is reported through onRecoverableError (the default logs it to the console) rather than thrown, because the app recovered.
  5. Real-world causes: Date.now() or Math.random() in render, typeof window !== "undefined" branches, locale-dependent formatting, browser extensions changing the HTML. Fix by rendering the same thing on both sides and moving client-only values into an effect, or suppressHydrationWarning for an intentional difference like a timestamp.

Learn more: Hydration.

Explain this: what does 'use client' actually mark?
app/page.jsx
// Server Component (the default in the Next.js App Router)
import { db } from "./db";
import LikeButton from "./LikeButton";

export default async function Page() {
  const post = await db.post.findFirst();
  return (
    <article>
      <h1>{post.title}</h1>
      <LikeButton postId={post.id} initialLikes={post.likes} />
    </article>
  );
}
app/LikeButton.jsx
"use client";
import { useState } from "react";

export default function LikeButton({ postId, initialLikes }) {
  const [likes, setLikes] = useState(initialLikes);
  return <button onClick={() => setLikes(likes + 1)}>♥ {likes}</button>;
}
Show answer

"use client" marks a module boundary: this file, and everything it imports, is sent to the browser as JavaScript and can use state, effects and event handlers. Everything else stays a Server Component: it runs only on the server, can be async and read the database directly, and sends rendered output, not code, to the browser. (Next.js examples weren't executed in the sandbox.)

What’s happening
  1. In the App Router, components are Server Components by default. Page awaits the database during the request; its code, and the db module, never reach the browser bundle.
  2. LikeButton.jsx starts with "use client", so it's a Client Component. It's rendered to HTML on the server too, then hydrated in the browser, where useState and onClick work.
  3. Props passed from a Server Component to a Client Component cross the network, so they must be serializable: strings, numbers, plain objects, arrays, Dates, promises, and JSX. Not functions (except Server Functions marked "use server") and not class instances.
  4. A Server Component can be passed into a Client Component as children, and it stays a Server Component. But a Client Component can't import a Server Component: anything a client module imports becomes client code.
  5. Putting useState in a file without "use client" fails at build time with an error saying useState only works in a Client Component and suggesting you add the directive. Interview summary: "Server Components for data and static UI, small Client Components at the leaves for interactivity, so less JavaScript ships."

Learn more: React Server Components and 'use client' and 'use server'.

Spot the bug: two tests that look fine
Toggle.test.jsx
test("toggles on", async () => {
  const user = userEvent.setup();
  render(<Toggle />); // a button showing "Off", then "On" after a click
  user.click(screen.getByRole("button"));
  expect(screen.getByRole("button")).toHaveTextContent("On");
});

test("no error message", () => {
  render(<p>All good</p>);
  expect(screen.getByText("Error")).not.toBeInTheDocument();
});
Show answer

The first test is missing await on user.click: the assertion runs before the click happens (the button still read "Off" right after the call, and "On" a moment later). The second uses getByText to assert absence, which throws "Unable to find an element with the text: Error" instead of passing. Use await user.click(...) and queryByText.

What’s happening
  1. user-event 14's methods are async: they dispatch pointer and keyboard events with small pauses between them, like a browser. user.click(...) returns a promise immediately, before the click event has fired.
  2. Without await, expect runs against the unchanged DOM and fails with "Off". Even when such a test passes by luck, the stray update can land in the next test and cause an act warning there.
  3. getBy… throws when there's no match. In the second test that happens inside the expect(...) argument, before .not.toBeInTheDocument() ever runs, so the test fails for the opposite of the intended reason.
  4. queryByText("Error") returns null when nothing matches, and expect(null).not.toBeInTheDocument() passes. The rule from the queries table: getBy for present, queryBy for absent, findBy for will-appear.
  5. The ESLint plugins eslint-plugin-testing-library and @typescript-eslint/no-floating-promises flag both mistakes.

Learn more: User interactions with user-event and Queries: getBy, queryBy and findBy.

Explain the difference: getByText, findByText and waitFor
React
fireEvent.click(screen.getByText("Fetch Data"));

expect(screen.getByText("Fetched Data")).toBeInTheDocument();                         // 1
expect(await screen.findByText("Fetched Data")).toBeInTheDocument();                  // 2
await waitFor(() => expect(screen.getByText("Fetched Data")).toBeInTheDocument());    // 3

The click starts a fetch. Which lines pass?

Show answer

1 fails, 2 and 3 pass. getByText checks once, right now, while the request is still pending. findByText and waitFor retry until the text appears or 1000 ms pass. findByText is shorthand for waitFor + getByText.

What’s happening
  1. fireEvent.click runs the handler up to its first await. The fetch promise can only resolve in a later microtask, so when line 1 runs, the DOM shows "Loading…". In the site's real run it failed with "Unable to find an element with the text: Fetched Data" and a DOM dump showing "Loading…", plus two "not wrapped in act(...)" warnings for the updates that landed after the test ended.
  2. Line 2 polls every 50 ms (and on DOM changes) inside RTL's async act handling. The response arrives, React re-renders, the query finds the text and the promise resolves.
  3. Line 3 does the same with an explicit callback, retried until it stops throwing. It's my notes' waitFor example; it's equivalent to line 2 but wordier.
  4. When to use which: findBy to wait for an element, waitFor for other conditions (a mock called, a value changed), getBy only when the element is already there. Never put side effects inside waitFor, since it re-runs the callback.

Learn more: Testing asynchronous behaviour.

Explain this: why share React as a singleton in Module Federation?
webpack.config.js
new ModuleFederationPlugin({
  name: "HostApp",
  remotes: { RemoteApp: "RemoteApp@http://localhost:3001/remoteEntry.js" },
  shared: {
    react: { singleton: true },
    "react-dom": { singleton: true },
  },
});
Show answer

Because React must be loaded once per page. If the host and a remote each bundle their own copy, hooks in the remote's components call into a different React than the one rendering them, and you get "Invalid hook call" errors and context that doesn't cross the boundary. singleton: true makes every app use one shared copy at runtime. (Module Federation configs weren't executed in the sandbox.)

What’s happening
  1. Module Federation lets a host app load components from separately built and deployed remotes at runtime, via each remote's remoteEntry.js. That's how my notes' SMT / Command Center micro-frontends were composed.
  2. Hooks keep their state in the React instance that's currently rendering. A remote component that imports its own React calls useState on an instance that isn't rendering anything, which is why React's "Invalid hook call" error lists "You might have more than one copy of React in the same app" as a cause.
  3. Context breaks the same way: a provider from one React copy is invisible to useContext from another, so the host's theme or auth context wouldn't reach remote components.
  4. shared: { react: { singleton: true } } tells the runtime to load one version and give it to everyone. Adding requiredVersion makes mismatches visible as warnings; keeping React versions aligned across teams is part of running micro-frontends.
  5. Shared singletons apply to anything with global state: React, react-dom, the router, a design system's theme provider, a Redux store if it's shared.

Learn more: Micro-frontends and Module Federation in practice.

Built from Rohit’s “React Interview concepts” doc. Examples target React 19.3 and are tested with Vitest and React Testing Library.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.