Episode 2 — React Frontend Architecture NextJS / 2.3 — State and Rerendering Logic

2.3 — Interview Questions

18 interview questions with model answers, organized by difficulty level.

Navigation: ← Exercise Questions · Quick Revision →


Beginner (Questions 1-6)

Q1. What is state in React and why do we need it?

Model Answer:

State is data that belongs to a component and can change over time. When state changes, React automatically re-renders the component to reflect the new data in the UI.

We need state because regular JavaScript variables have two problems in React components:

  1. They don't persist between renders -- since component functions re-run on each render, local variables reset to their initial value.
  2. Changing them doesn't trigger a re-render -- React has no way to know a local variable changed.

useState solves both: it stores the value outside the function (in React's internal memory) and provides a setter function that tells React to re-render when called.

// Doesn't work -- count resets to 0 on every render, no re-render triggered
function Counter() {
  let count = 0;
  return <button onClick={() => count++}>{count}</button>;
}

// Works -- count persists and setter triggers re-render
function Counter() {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount(prev => prev + 1)}>{count}</button>;
}

Q2. What is the difference between props and state?

Model Answer:

AspectPropsState
OwnershipOwned by the parentOwned by the component itself
MutabilityRead-only (immutable inside the component)Mutable via setter function
FlowTop-down (parent to child)Local to the component
PurposeConfigure a component from outsideTrack data that changes over time

They work together: a parent's state often becomes a child's props. This is how data flows in React -- a parent calls useState, then passes the value down as a prop. The child reads it but cannot modify it directly; instead, the parent passes down a callback that the child calls to request a change.


Q3. What does useState return and how do you use it?

Model Answer:

useState returns an array with exactly two elements:

  1. The current state value
  2. A setter function to update the state
const [count, setCount] = useState(0);
//     ^^^^^  ^^^^^^^^           ^
//     value  setter         initial value (used on first render only)

The initial value argument is only used on the first render. On subsequent renders, React ignores it and returns the stored value.

To update state, call the setter with a new value (setCount(5)) or with a function that computes the new value from the old one (setCount(prev => prev + 1)).

Use the functional form when the new value depends on the previous value, especially when multiple updates happen in the same event handler.


Q4. Why must you treat state as immutable in React?

Model Answer:

React uses reference equality (===) to detect state changes for objects and arrays. If you mutate an object and pass the same reference to the setter, React sees the same reference and skips the re-render.

// BAD: same reference, React doesn't re-render
user.name = "Bob";
setUser(user); // user === user is true, no re-render

// GOOD: new reference, React detects the change
setUser({ ...user, name: "Bob" }); // new object !== old object

Beyond re-render detection, immutability also matters for:

  • Predictable state snapshots (closures capture specific values)
  • React's concurrent features (which may render multiple versions simultaneously)
  • Debugging (you can compare old and new state objects)
  • Performance optimizations like React.memo that rely on reference equality

Q5. What are the three things that trigger a component to re-render?

Model Answer:

  1. State change: When the component calls its own setState with a new value (different reference for objects, different value for primitives).

  2. Parent re-render: When a parent component re-renders, all of its children re-render by default, regardless of whether their props changed.

  3. Context change: When a context value that the component consumes (via useContext) changes.

A common misconception is that "props changing" triggers re-renders. It's actually the parent re-rendering that triggers the child -- the props may or may not have changed. You can use React.memo to skip re-rendering when props are the same.


Q6. What is derived state and why should you avoid storing it?

Model Answer:

Derived state is a value that can be computed from existing state or props. For example, if you have an items array in state, the itemCount is derived -- it's items.length.

You should avoid storing derived state because:

  1. It creates a synchronization problem -- you must manually update the derived value every time the source changes. Miss one update path and you have a bug.
  2. It adds unnecessary complexity -- more useState calls, more useEffect calls to sync them.
  3. It causes extra renders -- updating derived state via useEffect causes a second render after each source change.

Instead, compute derived values during render:

// BAD
const [items, setItems] = useState([]);
const [count, setCount] = useState(0);
useEffect(() => setCount(items.length), [items]);

// GOOD
const [items, setItems] = useState([]);
const count = items.length; // Always in sync, no extra render

Intermediate (Questions 7-12)

Q7. Explain React's snapshot model of state. Why does console.log(count) show the old value right after setCount?

Model Answer:

When React renders a component, it provides a "snapshot" of the state at that moment. For the entire duration of that render -- including all event handlers created during that render -- the state values are fixed.

function Counter() {
  const [count, setCount] = useState(0);

  function handleClick() {
    setCount(count + 1);
    console.log(count); // Still 0 -- this is the snapshot value
  }
}

setCount doesn't modify count in place. It schedules a new render with a new value. The current closure still holds the old snapshot.

This is by design -- it prevents a class of timing bugs. If you click a button and a setTimeout fires later, the timeout uses the state value from when the click happened, not whatever the "latest" value might be. This makes behavior predictable: each render sees a consistent snapshot.

If you need the "next" value, compute it into a variable before calling setState:

function handleClick() {
  const nextCount = count + 1;
  setCount(nextCount);
  console.log(nextCount); // 1 -- this is the computed value
}

Q8. What is batching in React 18, and how does it differ from React 16/17?

Model Answer:

Batching is React's optimization of grouping multiple state updates into a single re-render.

In React 16/17, batching only worked inside React event handlers. State updates inside setTimeout, Promise.then, native event listeners, or after await were NOT batched -- each setState caused its own re-render.

React 18 introduced automatic batching, which batches state updates in ALL contexts:

// React 16/17: 2 re-renders (not batched in setTimeout)
// React 18:    1 re-render (automatically batched)
setTimeout(() => {
  setCount(1);
  setName("Alice");
}, 1000);

This requires using the createRoot API instead of the legacy render.

The practical impact: fewer re-renders, better performance, and no more inconsistent intermediate states visible to the user. If you need to opt out of batching (rare), use flushSync from react-dom.


Q9. setCount(count + 1) called 3 times gives 1. setCount(prev => prev + 1) called 3 times gives 3. Explain why.

Model Answer:

When you call setCount(count + 1), the value of count is captured from the current closure (the render snapshot). If count is 0, all three calls evaluate to setCount(0 + 1), which is setCount(1) three times. The queue becomes [1, 1, 1] and the final value is 1.

When you call setCount(prev => prev + 1), you pass a function. React processes the update queue by feeding the result of each function as input to the next:

Queue: [fn, fn, fn] where fn = prev => prev + 1

Processing:
  Start: 0
  fn(0) = 1
  fn(1) = 2
  fn(2) = 3
  Final: 3

The key difference: direct values use the closure's snapshot (fixed), while functional updates receive the latest accumulated value from the queue (dynamic). Always use functional updates when the new state depends on the old state.


Q10. A child component re-renders every time its parent re-renders, even though the child's props haven't changed. Why? How do you prevent it?

Model Answer:

React's default behavior is to re-render all children when a parent re-renders. React does NOT check whether props changed before re-rendering children -- it simply calls every child function again.

This is by design: checking prop equality on every render would itself cost time, and most re-renders are cheap. React optimizes for correctness first.

To prevent unnecessary child re-renders, use React.memo:

const Child = React.memo(function Child({ name }) {
  return <p>{name}</p>;
});

React.memo wraps the component with a shallow prop comparison. If all props are the same by reference, React skips the re-render.

However, React.memo breaks if you pass new object/function references each render:

// This defeats React.memo because {} !== {} on each render
<MemoizedChild style={{ color: "blue" }} onClick={() => doSomething()} />

// Fix: stabilize references
const style = useMemo(() => ({ color: "blue" }), []);
const handleClick = useCallback(() => doSomething(), []);
<MemoizedChild style={style} onClick={handleClick} />

Important: only apply these optimizations when there's a measurable performance problem. Premature memoization adds complexity.


Q11. What's wrong with using useEffect to compute derived state? Show the better approach.

Model Answer:

Using useEffect to sync derived state has three problems:

  1. Extra render: The component renders first with stale derived data, then the effect fires, updates state, and causes a second render with correct data. The user may briefly see incorrect UI.

  2. Unnecessary complexity: You need useState + useEffect + dependency array for something that could be one line.

  3. Bug risk: If you forget to include a dependency, the derived value becomes stale.

// BAD: Extra render, unnecessary complexity
const [items, setItems] = useState([...]);
const [total, setTotal] = useState(0);

useEffect(() => {
  setTotal(items.reduce((sum, i) => sum + i.price, 0));
}, [items]);
// Render 1: total is stale
// Effect fires: setTotal
// Render 2: total is correct

// GOOD: Computed during render, always correct
const [items, setItems] = useState([...]);
const total = items.reduce((sum, i) => sum + i.price, 0);
// One render, total is immediately correct

If the computation is expensive, use useMemo instead of useEffect:

const total = useMemo(
  () => items.reduce((sum, i) => sum + i.price, 0),
  [items]
);

useMemo computes during the same render (no extra render), and only recomputes when dependencies change.


Q12. Explain React's reconciliation algorithm. What are its two key assumptions?

Model Answer:

Reconciliation is React's process of comparing the old virtual DOM tree with the new one to determine the minimum DOM operations needed.

The two key assumptions that make this efficient (O(n) instead of O(n^3)):

Assumption 1: Elements of different types produce entirely different trees. If a <div> becomes a <section>, React destroys the entire old subtree and builds a new one from scratch. It doesn't try to reuse anything underneath.

Assumption 2: Developer-provided key props identify which child elements are stable across renders. In lists, React uses keys to match items between the old and new tree. Without keys, React compares by position, which is inefficient when items are reordered.

Practical implications:

  • Don't change the component type at a position unless you want a full remount (state loss).
  • Always provide stable, unique keys for list items (not array indices if the list can reorder).
  • Keep component hierarchy stable to preserve state across renders.

Advanced (Questions 13-18)

Q13. Explain how React Fiber enables concurrent rendering. What problem did it solve over the previous architecture?

Model Answer:

Before Fiber (React 15 and earlier), React used a synchronous, recursive "stack reconciler." Once rendering started, it ran to completion without interruption. For complex component trees, this could block the main thread for hundreds of milliseconds, causing visible jank.

Fiber introduced a data structure where each component instance corresponds to a "Fiber node." These nodes form a linked tree with child, sibling, and return (parent) pointers. Each node represents a "unit of work."

Key capabilities Fiber enables:

  1. Incremental rendering: React can process one Fiber node at a time, then check if there's higher-priority work (like user input). If so, it pauses the current render and handles the urgent work first.

  2. Double buffering: React maintains two Fiber trees -- "current" (on screen) and "work-in-progress" (being built). When the WIP tree is complete, React swaps them. If WIP is abandoned (due to interruption), the current tree is unchanged.

  3. Priority lanes: Updates are assigned priority levels. A startTransition update has lower priority than a direct setState in a click handler. React processes higher-priority updates first.

  4. Concurrent features: useTransition, useDeferredValue, and Suspense all rely on Fiber's ability to pause, abort, and restart renders.

The practical impact: React can keep the UI responsive during expensive renders by yielding to the browser between work units (typically every ~5ms).


Q14. How does startTransition work, and when would you use it?

Model Answer:

startTransition marks a state update as non-urgent. React can interrupt the resulting render if higher-priority work arrives (like user typing).

function SearchPage() {
  const [query, setQuery] = useState("");
  const [results, setResults] = useState([]);

  function handleChange(e) {
    setQuery(e.target.value); // Urgent: update input immediately
    startTransition(() => {
      setResults(searchLargeDataset(e.target.value)); // Non-urgent: can be interrupted
    });
  }
}

How it works internally: the state update inside startTransition is assigned a lower priority lane. If the user types another character before the transition render completes, React abandons the in-progress render and starts a new one with the latest input.

Use cases:

  • Search filtering over large datasets
  • Tab switching with heavy content rendering
  • Navigation transitions
  • Any update where the input should remain responsive but the derived output can lag slightly

useTransition is the hook version that also provides an isPending boolean for showing loading indicators:

const [isPending, startTransition] = useTransition();
// isPending is true while the transition render is in progress

Q15. A component tree has 500 components. State changes at the root. Walk through exactly what React does, and explain every optimization opportunity.

Model Answer:

When root state changes:

  1. React schedules a re-render of the root component.

  2. Render phase begins. React calls the root component function, gets new JSX. Then recursively calls each child component function -- all 500 of them, top to bottom. Each function runs, producing new React elements.

  3. Diffing happens as React walks the tree. For each component, it compares the new element tree with the previous one. For most unchanged subtrees, the diff finds no changes.

  4. Commit phase. React collects all the DOM operations identified during diffing and applies them in a batch. Only the DOM nodes that actually changed get updated.

Optimization opportunities:

a. React.memo on subtrees: If a child's props haven't changed (by reference), React.memo skips calling that component function entirely. This prunes the entire subtree below it.

b. Move state down: If the state is only used by a small subtree, move it into that subtree. Then only that subtree re-renders, not all 500.

c. Children as props pattern: If the root passes children via the children prop, those children were created by the root's parent and their references are stable:

function Root({ children }) {
  const [count, setCount] = useState(0);
  return <div><p>{count}</p>{children}</div>;
  // children doesn't change when count changes -- React skips re-rendering it
}

d. Context splitting: If the state is in context, split it so that only consumers of the changed value re-render.

e. useMemo/useCallback: Stabilize object and function references passed as props to memoized children.

f. Virtualization: If the 500 components are a list, render only visible items (~20-50) using react-window or react-virtuoso.


Q16. Explain the difference between useMemo and storing computed values in state with useEffect. When is useMemo appropriate and when is it unnecessary?

Model Answer:

useEffect + useState:

const [items, setItems] = useState([...]);
const [sorted, setSorted] = useState([]);
useEffect(() => {
  setSorted([...items].sort(compareFn));
}, [items]);

Render 1: sorted is stale (old value). Effect fires after render, calls setSorted. Render 2: sorted is correct. The user sees a flash of stale data.

useMemo:

const [items, setItems] = useState([...]);
const sorted = useMemo(() => [...items].sort(compareFn), [items]);

Render 1: sorted is computed synchronously during this render. Always correct, no extra render.

When useMemo is appropriate:

  • The computation takes measurably long (>1ms)
  • The dependencies rarely change but the component re-renders often for other reasons
  • The result is passed as a prop to a memoized child (stabilizing the reference)

When useMemo is unnecessary:

  • Simple computations (arithmetic, string concatenation, small array operations)
  • The component rarely re-renders anyway
  • The dependencies change on every render (useMemo does nothing if deps always change)

The default should be computing as a plain variable. Add useMemo only after measuring a performance problem.


Q17. What are the implications of React's snapshot model for closures in event handlers and timeouts?

Model Answer:

Each render creates a closure over the state values at that point in time. Event handlers, timeouts, and intervals defined during a render capture that render's state snapshot.

function Chat() {
  const [message, setMessage] = useState("");

  function handleSend() {
    // This captures 'message' from the render when handleSend was created
    setTimeout(() => {
      sendToServer(message); // Uses the snapshot, not the "latest" value
    }, 3000);
  }
}

Implications:

  1. Timeouts see old values. If the user types "Hello", clicks Send, then changes the text to "Goodbye" before the timeout fires, the server receives "Hello" (the snapshot at click time). This is usually correct behavior -- you sent what was on screen when you clicked.

  2. Intervals with direct state access get stuck. setInterval(() => setCount(count + 1), 1000) always adds 1 to the same initial count value because the closure captured it once. Fix: use functional updates setCount(prev => prev + 1).

  3. Async functions see the snapshot from when they started. After await, the local variables still hold the pre-await snapshot values. The component may have re-rendered multiple times in between.

  4. This is why functional updates exist. setCount(prev => prev + 1) doesn't rely on the closure -- it receives the latest value from React's update queue.

If you genuinely need the "latest" value regardless of render timing, use a ref:

const countRef = useRef(count);
countRef.current = count; // Update on every render
// Now countRef.current always has the latest value in any callback

Q18. Design the state architecture for a real-time collaborative text editor. Address: what is state, what is derived, how do you handle concurrent edits, and what are the performance considerations?

Model Answer:

Core state:

  • document: The canonical text content (or operational transform / CRDT data structure)
  • cursorPosition: Local user's cursor position
  • selection: Local user's selection range
  • collaborators: Map of other users' cursor positions and selections (from WebSocket)
  • connectionStatus: "connected" | "disconnected" | "reconnecting"

Derived values (not state):

  • wordCount, charCount, lineCount -- computed from document
  • displayText -- rendered from document state
  • isCollaboratorNearby -- computed from collaborators positions relative to local cursor
  • hasUnsyncedChanges -- computed from local vs server document version

Concurrent edit handling:

  • Use Operational Transformation (OT) or CRDT (Conflict-free Replicated Data Types) for the document model
  • Local edits apply immediately (optimistic update) -- setDocument with the local change
  • Remote edits arrive via WebSocket -- transform against pending local operations, then apply
  • Server is the source of truth for document version
  • This is where useReducer shines over useState -- complex update logic with multiple action types (insert, delete, transform, merge)

Performance considerations:

  • Document state changes on every keystroke -- the entire component tree should NOT re-render
  • Split the editor into regions: only the visible viewport re-renders
  • Use React.memo on collaborator avatars (they rarely move)
  • Use useMemo for word count/line count (debounced or throttled)
  • Cursor position updates from other users are high-frequency -- use startTransition or debounce
  • The actual text rendering should use a ref-based approach (like CodeMirror or ProseMirror) rather than React state, because React's re-render cycle is too slow for character-by-character typing at scale
  • Connection status changes are rare -- use Context for global access

Architecture:

EditorProvider (Context: document, dispatch, collaborators)
  |-- Toolbar (reads selection, dispatches formatting)
  |-- EditorCanvas (ref-based text rendering, not React state)
  |    |-- CollaboratorCursors (reads collaborators)
  |    |-- LocalCursor (reads cursorPosition)
  |-- StatusBar (reads wordCount, connectionStatus)
  |-- CollaboratorList (reads collaborators)

The key insight: for a high-performance editor, React state manages the application-level concerns (collaboration, connection, toolbar state), while the actual text editing is delegated to a specialized library that directly manipulates the DOM, bridged to React via refs.


Navigation: ← Exercise Questions · Quick Revision →