React Interview Questions and Answers

Last updated:

Check out 60 of the most common React interview questions, then take an AI-powered practice interview

60+
Questions
24
Basic
24
Intermediate
12
Advanced
Q1

When state changes in React, what actually happens between the setState call and the screen updating?

BasicFundamentals

Answer

Calling a state setter does not update anything immediately. React schedules a re-render, and during that render phase it calls your component function again to produce a new tree of React elements (plain objects describing the UI). It then reconciles: it walks the new element tree against the existing Fiber tree, comparing element types and keys to decide which DOM nodes can be kept, which need updating, and which must be destroyed and recreated.

Only after the whole comparison completes does React enter the commit phase, where it applies the minimal set of DOM mutations synchronously, runs useLayoutEffect callbacks, paints, and then runs useEffect callbacks. The 'virtual DOM' is just this pair of ideas: UI described as cheap immutable objects, plus a diffing step that translates the difference between two descriptions into imperative DOM operations. The value is not raw speed (direct DOM manipulation is faster in isolation); the value is that you write declarative code and React batches and minimises DOM work for you.

Interviewers probe two things here. First, that you know rendering and committing are separate phases, which is why render functions must be pure and why a render can be thrown away entirely under concurrent rendering without the user ever seeing it. Second, that you know state updates inside event handlers are batched: three setter calls in one click handler produce one render, not three. Since React 18 this batching also applies inside promises, setTimeout, and native event callbacks (automatic batching), which older tutorials get wrong.

Key Points

  • Render phase: component functions run and produce element trees, no DOM touched
  • Reconciliation diffs new elements against the Fiber tree using type and key
  • Commit phase applies minimal DOM mutations, then layout effects, then passive effects
  • State updates are batched; multiple setters in one handler cause one render
  • Automatic batching since React 18 covers timeouts, promises, and native events
Q2

What does JSX compile to since the automatic runtime, and why do you no longer need to import React in every file?

BasicFundamentals

Answer

JSX is syntax sugar that a compiler (esbuild inside Vite, SWC inside Next.js, or Babel) transforms into function calls. Before React 17, <div id='x' /> compiled to React.createElement('div', { id: 'x' }), which is why every file needed 'import React'. The automatic runtime (introduced with React 17 and required by React 19) compiles JSX to calls to jsx() and jsxs() imported automatically from 'react/jsx-runtime', so the React identifier never appears in the output and the manual import is unnecessary.

The functions return plain objects with a $$typeof: Symbol.for('react.transparent... ') marker, type, props, and key fields; that Symbol tag is also a security feature, because JSON from a server can never contain a Symbol, so injected JSON cannot masquerade as a React element. Two follow-ups interviewers like: first, JSX is not HTML. Attributes follow DOM property naming (className, htmlFor, onClick camelCased), style takes an object, and every tag must be closed.

Second, an element is a description, not an instance: <MyComponent /> does not call MyComponent, it creates { type: MyComponent, props: {} }, and React itself decides when and whether to invoke it. That distinction explains why calling a component directly as a function, MyComponent(), breaks hooks: React has no Fiber to attach the hook state to. In 2026 you should also mention that React 19 dropped support for the old createElement transform in new code paths and that upgrading old CRA-era projects usually starts with switching the JSX transform in the build tool config.

// What you write
const el = (
  <button className='cta' onClick={handleClick}>
    Pay now
  </button>
);

// What the automatic runtime emits (simplified)
import { jsx as _jsx } from 'react/jsx-runtime';
const el2 = _jsx('button', {
  className: 'cta',
  onClick: handleClick,
  children: 'Pay now',
});

// An element is just data, not a DOM node and not a component call
console.log(el2.type); // 'button'
console.log(el2.props.children); // 'Pay now'
💡 Pro Tip: If asked 'what is JSX', never answer 'HTML in JavaScript'. Say it compiles to jsx() calls that return element objects, then explain one consequence, like why className is used instead of class.
Q3

Why are props read-only, and what does one-way data flow mean when a child needs to change parent state?

BasicFundamentals

Answer

Props are the arguments a parent passes when it renders a child, and React treats them as immutable snapshots: a component must never assign to props, because React relies on comparing prop references between renders to decide what changed. If children mutated props, the parent's next render would silently overwrite those mutations and nothing would be trackable. One-way data flow means data travels down through props and intent travels up through callbacks.

When a child needs to change something the parent owns, the parent passes a function (onSelect, onChange, onDelete) and the child calls it; the parent updates its own state, re-renders, and the new value flows back down. This sounds ceremonial but is what makes large React apps debuggable: for any piece of UI you can trace exactly which component owns the state and every place that can change it. Interviewers often test this with a practical scenario: a list component where deleting an item should update the parent's array.

The wrong answers mutate the array received via props or copy props into local state (a duplication anti-pattern that immediately causes stale-data bugs). The right answer calls props.onDelete(id) and lets the owner produce a new array with filter. A related probe: 'props drilling is getting deep, what do you do?'

Reasonable answers are component composition (pass composed children instead of data), Context for genuinely global values like theme or session, or moving the state into a store. Reaching for Redux because two levels of drilling felt annoying is the answer that fails the question.

Key Points

  • Props are immutable snapshots; never assign to them or copy them into state
  • Data flows down via props, intent flows up via callback props
  • The component that owns state is the only one that changes it
  • Fix deep drilling with composition or Context, not reflexively with a store
Q4

Why does calling setCount(count + 1) three times in one handler increment by one, and how do functional updates fix it?

BasicHooks

Answer

Because state in a function component is a snapshot captured by the closure of that particular render. If count is 0 when the handler runs, all three calls evaluate to setCount(0 + 1); React batches them and the queue ends up as 'replace with 1' three times, so the next render shows 1. The fix is the functional (updater) form: setCount(c => c + 1).

React queues the functions and, when it processes the update queue, feeds each one the latest intermediate value, so three updaters yield 3. This is the single most common React interview live-coding trap, and it has a practical production shape: any setter called inside setInterval, setTimeout, a WebSocket handler, or an async continuation should use the updater form, because the closure it lives in may be many renders old. A second detail worth volunteering: useState's initialiser runs on every render if you write useState(expensiveCompute()), because the argument is evaluated eagerly even though React ignores it after mount.

Pass the function itself, useState(() => expensiveCompute()), to run it only once (lazy initialisation). Third: setting state to the same value (compared with Object.is) makes React bail out and skip re-rendering children, though React may still run the component function one more time before bailing, which surprises people who put console.log in the body. Being able to explain all three behaviours, snapshots, updater queues, and lazy initialisation, signals you actually understand useState rather than just using it.

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

  function brokenTripleClick() {
    setCount(count + 1); // queue: replace with 1
    setCount(count + 1); // queue: replace with 1
    setCount(count + 1); // queue: replace with 1  -> renders 1
  }

  function correctTripleClick() {
    setCount(c => c + 1);
    setCount(c => c + 1);
    setCount(c => c + 1); // -> renders 3
  }

  // Lazy initialisation: runs once, not on every render
  const [table, setTable] = useState(() => buildLookupTable());

  return <button onClick={correctTripleClick}>{count}</button>;
}
💡 Pro Tip: Rule of thumb to say out loud: if the next state depends on the previous state, always use the updater form. It also makes handlers safe to call from stale closures like intervals.
Q5

Explain the useEffect dependency array: what does each variant do, and when exactly does cleanup run?

BasicHooks

Answer

useEffect(fn) with no array runs after every commit. useEffect(fn, []) runs after the initial mount only (twice in development under StrictMode, which is intentional). useEffect(fn, [a, b]) runs after mount and again after any commit where a or b changed by Object.is comparison. The cleanup function returned by the effect runs before the effect re-runs with new values, and once more when the component unmounts. So for a subscription effect the sequence over time is: subscribe(v1), then on change: unsubscribe(v1), subscribe(v2), and on unmount: unsubscribe(v2).

Common mistakes interviewers look for: omitting a dependency the effect reads (creates a stale closure; the effect keeps seeing old values), suppressing the exhaustive-deps ESLint rule instead of restructuring, putting objects or arrays created inline in the dependency array (new reference every render, so the effect runs every time; hoist them, memoise them, or depend on primitive fields instead), and using an effect where none is needed. That last one matters most in 2026: the official docs' 'You Might Not Need an Effect' guidance is now standard interview material. Deriving state from props, resetting state when a prop changes (use the key prop instead), and responding to a button click (do it in the event handler) are all cases where reaching for useEffect is wrong. The strongest answer frames effects as synchronisation with external systems, network, DOM APIs, timers, analytics, subscriptions, rather than 'code that runs after render'.

useEffect(() => {
  const socket = new WebSocket(`wss://api.example.com/rooms/${roomId}`);
  socket.addEventListener('message', onMessage);

  return () => {
    // Runs before the next effect execution AND on unmount
    socket.close();
  };
}, [roomId]); // re-connect only when roomId changes

// Anti-pattern: derived state via effect
// useEffect(() => setFullName(first + ' ' + last), [first, last]);
// Correct: compute during render
const fullName = first + ' ' + last;

Key Points

  • No array: every commit. []: mount only. [deps]: when a dep changes by Object.is
  • Cleanup runs before each re-run and on unmount
  • Inline object/array deps re-trigger effects every render
  • Effects are for syncing with external systems, not for deriving state
Q6

Controlled versus uncontrolled inputs: what breaks if you pass value without onChange, and when is each approach right?

BasicForms

Answer

A controlled input takes its display value from React state: you pass value={text} and onChange={e => setText(e.target.value)}. The DOM no longer owns the value; every keystroke flows through React, which makes instant validation, formatting (uppercasing a PAN number, masking a phone number), and conditional disabling trivial. If you pass value without onChange, React renders a read-only input and logs a console warning; users type and nothing appears, a classic debugging question.

Passing value={undefined} on the first render and a string later triggers the 'component is changing an uncontrolled input to be controlled' warning, which usually means your state was initialised to undefined instead of ''. An uncontrolled input keeps the value in the DOM; you set defaultValue for the initial text and read the current value on demand via a ref or, in modern code, via FormData when the form submits. React 19 leans into this: a <form action={fn}> receives FormData directly, so simple forms no longer need a useState per field at all.

When to use which: controlled when the UI must react per keystroke (live search, character counters, dependent fields, instant validation); uncontrolled with FormData for straightforward submit-and-validate forms, where it removes a re-render per keystroke and a pile of boilerplate. Libraries split the same way: Formik is fully controlled, while react-hook-form is mostly uncontrolled with refs, which is exactly why react-hook-form performs better on large forms and became the default recommendation. Mentioning that file inputs are always uncontrolled (you cannot set their value programmatically for security reasons) is a nice completeness point.

// Controlled: React state is the source of truth
function PanField() {
  const [pan, setPan] = useState('');
  return (
    <input
      value={pan}
      onChange={e => setPan(e.target.value.toUpperCase().slice(0, 10))}
      placeholder='ABCDE1234F'
    />
  );
}

// Uncontrolled with React 19 form action: no per-field state
function Signup() {
  async function register(formData) {
    await api.signup({
      email: formData.get('email'),
      city: formData.get('city'),
    });
  }
  return (
    <form action={register}>
      <input name='email' type='email' required />
      <input name='city' defaultValue='Bengaluru' />
      <button type='submit'>Create account</button>
    </form>
  );
}
Q7

Why does using the array index as a key corrupt component state when a list is reordered or an item is removed?

BasicRendering

Answer

Keys are how React matches children between renders. During reconciliation React pairs old and new children by key, keeps the Fiber (and therefore the component state and DOM node) for matching keys, and only mounts or unmounts where keys appear or disappear. With index keys, the key describes position, not identity.

Delete the first item of [A, B, C] and the new list [B, C] has keys 0 and 1: React thinks item 0 'changed its props from A to B' and item 2 was removed. Any state living inside those items, checkbox ticks, controlled input text, expanded/collapsed state, animation state, now belongs to the wrong logical item. Users see the wrong row's input text surviving a delete, which is one of the most reported real-world React bugs.

The fix is a stable identity key: a database id, a UUID generated at creation time (stored with the item, never generated during render, because that changes every render and forces a full remount of every row), or a natural unique field. Index keys are acceptable only when the list is static: never reordered, never filtered, never prepended, and items have no internal state. Two adjacent facts strengthen the answer: keys must be unique among siblings only, not globally; and deliberately changing a key is a legitimate technique to force a remount, for example <ProfileForm key={userId}> resets all form state when the user switches profiles, which is the idiomatic alternative to resetting state in an effect.

// Buggy: index keys + stateful rows
{todos.map((todo, i) => (
  <TodoRow key={i} todo={todo} /> // state sticks to position
))}

// Correct: identity keys
{todos.map(todo => (
  <TodoRow key={todo.id} todo={todo} />
))}

// Intentional remount via key change: resets ALL state inside
<ProfileForm key={selectedUserId} userId={selectedUserId} />
💡 Pro Tip: If the interviewer asks 'when is index as key fine', the answer is: render-only lists that never reorder, filter, or prepend. Then mention key-as-remount-trigger; it shows you understand keys are about identity, not list iteration.
Q8

Why does {count && <Badge />} render a literal 0 on screen, and what are the safe conditional rendering patterns?

BasicRendering

Answer

JSX renders most falsy values, false, null, undefined, and true, as nothing, but 0 and NaN are rendered as text, and an empty string renders as nothing visible while still being a valid child. The && operator returns its left operand when that operand is falsy, so when count is 0, count && <Badge /> evaluates to 0, and React prints a stray '0' into the DOM. This bites constantly in real code with things like items.length && <List items={items} />.

The fixes: coerce to a boolean (count > 0 && <Badge />, or !!count && ...), use a ternary (count ? <Badge /> : null), or return early from the component. In React Native this same bug is a crash rather than a cosmetic glitch, because a raw string outside a <Text> component throws, so interviewers at companies with React Native apps (Swiggy, Meesho) specifically test it. Related patterns worth stating: returning null from a component renders nothing but still runs the component and its hooks, so it is not a performance tool; conditional rendering with a ternary that switches between two different component types unmounts one and mounts the other, destroying state, whereas toggling visibility with CSS or the React 19.2 <Activity mode='hidden'> preserves state; and avoid nesting ternaries more than one level, extract a variable or a small function instead. A tidy pattern for multi-branch UI is assigning JSX to a variable in an if/else chain before the return, which stays readable and debuggable.

// Bug: renders '0' when cartItems is empty
<div>{cartItems.length && <CartSummary items={cartItems} />}</div>

// Safe variants
<div>{cartItems.length > 0 && <CartSummary items={cartItems} />}</div>
<div>{cartItems.length ? <CartSummary items={cartItems} /> : null}</div>

// Multi-branch: compute JSX before returning
let content;
if (status === 'loading') content = <Spinner />;
else if (status === 'error') content = <Retry onRetry={refetch} />;
else content = <Results data={data} />;
return <section>{content}</section>;
Q9

When should state be lifted up, when is Context the right tool, and where does prop drilling actually become a problem?

BasicState Management

Answer

Lift state up when two or more components need to read or write the same data: move it to their closest common ancestor and pass it down. The classic example is a filter input and a results list; the parent owns the query, the input writes it, the list reads it. Prop drilling, passing props through components that only forward them, is not automatically bad.

Two or three levels of explicit passing is more traceable than any alternative, and the first remedy should be composition, not Context: instead of <Layout user={user}> forwarding user down to <Avatar>, have the page render <Layout sidebar={<Profile user={user} />} /> so intermediate components never see the prop. Context is the right tool for genuinely ambient values read by many components at scattered depths: theme, locale, authenticated session, a design-system's density setting, the current tenant. It is a transport mechanism, not a state manager; it has no selectors, so every consumer re-renders when the value changes, which makes it a poor fit for rapidly-changing data like keystrokes or cursor position.

The interview trap here is jumping to Redux or Zustand the moment drilling appears. The strong answer sequences the tools: local state first, lift when shared, compose to avoid forwarding, Context for ambient rarely-changing values, an external store (Zustand, Redux Toolkit, Jotai) when you need cross-cutting client state with selective subscriptions, and a server-cache library (TanStack Query) for anything fetched from an API, because server data is a cache, not client state. Naming that separation is what distinguishes candidates in 2026.

Key Points

  • Lift state to the closest common ancestor that needs it, no higher
  • Try composition (passing elements as props) before Context
  • Context = ambient, rarely-changing values; every consumer re-renders on change
  • Server data belongs in TanStack Query or similar, not in Redux or Context
Q10

What did React 19 actually change compared to React 18, and which long-standing APIs were removed?

BasicVersions

Answer

React 19 (stable December 2024, with 19.1 and 19.2 following through 2025) is the biggest API shift since hooks. Additions: Actions, meaning async functions passed to <form action> or startTransition that manage pending state automatically; the useActionState hook (replacing the experimental useFormState) returning [state, action, isPending]; useFormStatus for reading the enclosing form's pending state from child components; useOptimistic for temporary optimistic values while an async action runs; and the use() API, which reads a promise or a Context inside render, suspending until the promise resolves, and unlike hooks may be called conditionally. ref became a normal prop on function components, so new code does not need forwardRef (it is deprecated and slated for removal). Refs can also return cleanup functions.

Document metadata tags (<title>, <meta>, <link>) rendered inside components are hoisted into <head> natively. Web Components support became first-class. Removals are equally interview-relevant: propTypes and defaultProps on function components are gone (use TypeScript and default parameters), string refs are gone, ReactDOM.render and hydrate are gone (createRoot and hydrateRoot only), findDOMNode is gone, and the old Legacy Context API is gone.

React 19.2 added the <Activity> component for hiding UI while preserving its state and useEffectEvent for reading fresh values inside effects without adding dependencies. Alongside the release line, the React Compiler reached stable and automates memoisation. Interviewers use this question to check that your knowledge is current rather than frozen at the hooks era; naming useActionState, ref-as-prop, and at least two removals is the bar.

Key Points

  • Actions + useActionState + useFormStatus + useOptimistic for async form flows
  • use() reads promises/Context in render and can be called conditionally
  • ref is a plain prop now; forwardRef deprecated; refs support cleanup functions
  • Removed: propTypes, defaultProps (function comps), string refs, ReactDOM.render, findDOMNode
  • 19.2 added <Activity> and useEffectEvent; React Compiler went stable separately
Q11

What is createPortal for, and how do events and Context behave across a portal boundary?

BasicRendering

Answer

createPortal(children, domNode) renders children into a different DOM container while keeping them in the same React tree. The canonical uses are modals, toasts, dropdown menus, and tooltips: anything that must escape a parent's overflow: hidden, z-index stacking context, or transform (a CSS transform on an ancestor creates a new containing block that breaks position: fixed, a real-world bug portals solve). The crucial conceptual point, and the part interviews test, is that the portal is a DOM escape hatch only.

In the React tree, the portal's children are still children of the component that rendered the portal. Consequences: Context flows into the portal normally, so a themed modal still reads ThemeContext; error boundaries above the portal catch its errors; and synthetic events bubble through the React tree, not the DOM tree, so an onClick on the component containing the portal fires when you click inside the modal, even though the modal's DOM lives in document.body. That last behaviour cuts both ways: it lets a parent implement 'close on outside click' incorrectly if it assumes DOM containment, and the standard fix is checking event.target against a ref with element.contains().

Production concerns worth raising: manage focus when a modal opens (move focus in, trap Tab, restore focus on close), set aria-modal and role='dialog', and prevent body scroll. In 2026 you should also mention the native <dialog> element with showModal() as a lighter alternative that gives focus trapping and the top-layer for free, with React 19 handling its props cleanly.

import { createPortal } from 'react-dom';

function Modal({ open, onClose, children }) {
  if (!open) return null;
  return createPortal(
    <div role='dialog' aria-modal='true' className='overlay' onClick={onClose}>
      <div className='panel' onClick={e => e.stopPropagation()}>
        {children}
        <button onClick={onClose}>Close</button>
      </div>
    </div>,
    document.body
  );
}

// Context still reaches the portal; clicks inside it still bubble
// to React ancestors of <Modal />, not to document.body ancestors.
Q12

How does React's synthetic event system work, and what changed about event delegation in React 17+?

BasicEvents

Answer

React does not attach your onClick to the individual DOM node. It attaches one native listener per event type at the root container (the element you passed to createRoot), and when a native event fires, React works out which components are on the path and calls their handlers in the right order with a SyntheticEvent, a cross-browser wrapper exposing the same interface as the native event (target, currentTarget, preventDefault, stopPropagation) plus nativeEvent for the raw object. Before React 17, delegation happened at document, which caused infamous interop bugs when a jQuery widget or another framework called stopPropagation at an intermediate node; since 17, delegation at the root container means multiple React versions and non-React code can coexist on one page, which matters for micro-frontend setups.

Event pooling, the old behaviour where the synthetic event object was recycled and e.target became null in async code, was removed in React 17, so e.persist() is dead API and a good trick question. Details worth knowing: handlers fire in the bubble phase by default, onClickCapture fires in the capture phase; stopPropagation on a synthetic event stops both synthetic and native propagation upward; some events do not bubble natively but React normalises them (onMouseLeave), and scroll notably does not bubble in React's system. Because delegation follows the React tree, events bubble 'through' portals to React ancestors, not DOM ancestors. Finally, if you attach a native window or document listener yourself inside useEffect, remember it sits outside this system entirely: it will fire in DOM order and needs manual cleanup.

Key Points

  • One native listener per event type at the createRoot container, not per node
  • SyntheticEvent normalises browsers; nativeEvent exposes the raw event
  • React 17 moved delegation from document to the root and removed event pooling
  • Bubbling follows the React tree, so portals bubble to React ancestors
  • Manual addEventListener in effects bypasses the synthetic system entirely
Q13

useRef versus useState: why does mutating ref.current not re-render, and what are refs legitimately used for?

BasicHooks

Answer

useRef returns a stable object { current } that survives re-renders. Writing to ref.current does not schedule a render because React does not track it; there is no setter, no update queue, no reconciliation. State is for values the UI is derived from; refs are for values that must persist across renders without driving the UI.

Legitimate ref uses fall into two buckets. First, DOM access: pass the ref via the ref attribute and React sets .current to the DOM node after commit (and back to null on unmount). You then call imperative APIs: focus(), scrollIntoView(), select(), measuring with getBoundingClientRect(), or integrating a non-React library like a chart or map that owns its own DOM.

Second, instance-like mutable storage: interval and timeout ids, an AbortController, a WebSocket instance, the previous value of a prop, a 'has this effect run' flag, or a latest-callback pattern where an effect reads ref.current to avoid stale closures. The classic interview bug: rendering {ref.current} in JSX and wondering why it never updates; the render happens only when something else triggers it, so the displayed value lags or never changes. The inverse bug is storing frequently-changing UI state in useState when nothing renders it (like mouse coordinates used only inside a handler), causing pointless re-render storms; that belongs in a ref.

Also worth saying: do not read or write refs during render (except lazy initialisation), because with concurrent rendering a render can be discarded or replayed and render-time mutation breaks purity. In React 19, ref is a regular prop on function components and ref callbacks can return cleanup functions, replacing the old null-call convention.

function StopWatch() {
  const [elapsed, setElapsed] = useState(0); // drives UI -> state
  const intervalRef = useRef(null); // bookkeeping -> ref

  function start() {
    if (intervalRef.current !== null) return; // already running
    intervalRef.current = setInterval(() => {
      setElapsed(e => e + 100);
    }, 100);
  }

  function stop() {
    clearInterval(intervalRef.current);
    intervalRef.current = null;
  }

  useEffect(() => stop, []); // cleanup on unmount

  return (
    <>
      <p>{(elapsed / 1000).toFixed(1)}s</p>
      <button onClick={start}>Start</button>
      <button onClick={stop}>Stop</button>
    </>
  );
}
Q14

Why must component render functions be pure, and what concretely goes wrong when they are not?

BasicRendering

Answer

React reserves the right to call your component function whenever it likes: to render, to re-render, to double-invoke in StrictMode, to speculatively render at low priority and then throw the result away under concurrent rendering, or to replay a render after hydration. That contract only holds if rendering is pure: same props, state, and context in, same JSX out, with no side effects during the call. Impure renders produce concrete, nasty bugs.

Mutating a prop or an object from an outer scope during render means a discarded concurrent render still leaves its mutation behind, so state diverges from UI. Firing network requests or analytics during render duplicates them unpredictably (StrictMode's deliberate double-invocation exists precisely to surface this in development). Reading Date.now(), Math.random(), or window.innerWidth during render makes server HTML and client hydration disagree, producing hydration mismatch errors.

Writing to a ref during render breaks when React replays the render. Calling a state setter during render creates an infinite loop (the one sanctioned exception is the derive-state-from-props pattern where you set state conditionally during render and React immediately re-renders before committing). Where do side effects belong?

Event handlers first, because most 'effects' are really responses to user actions; useEffect for synchronising with external systems; and module scope for one-time app setup. Local mutation is fine: building an array with push inside render is pure as long as the array was created during that same render. Interviewers increasingly phrase this as 'explain the Rules of React', since the React Compiler depends on these rules and refuses to optimise components that break them.

Key Points

  • Same inputs must produce same JSX; no side effects during the function call
  • Concurrent rendering can discard or replay renders; impurity leaks state
  • StrictMode double-invokes render and effects in dev to expose impurity
  • Date.now()/random/window reads during render cause hydration mismatches
  • The React Compiler only optimises components that follow these rules
Q15

What does StrictMode actually do in development, and why do effects mount, unmount, and mount again?

BasicTooling

Answer

StrictMode is a development-only wrapper (<StrictMode> around your tree, zero effect in production builds) that deliberately stresses your components to surface bugs early. Its two headline behaviours: it double-invokes rendering (component function bodies, useState/useMemo/useReducer initialisers and updaters) to catch impure renders, and since React 18 it runs every effect through an extra mount cycle: mount, run effects, immediately run cleanups, then run effects again. That mount-unmount-mount simulation exists to verify your effects are resilient to being set up and torn down more than once, which is exactly what React needs for fast-refresh in development and for reusable state features like the 19.2 <Activity> component, where hidden UI unmounts its effects but keeps state and may remount later.

The interview-relevant consequence: 'my API call fires twice in development' is not a React bug and the wrong fix is a useRef didRun guard or removing StrictMode. The right responses are: make the effect idempotent, cancel the first request in cleanup with AbortController, or better, move data fetching out of raw effects into TanStack Query or your framework's loader, which deduplicates naturally. StrictMode also warns about deprecated patterns (findDOMNode historically, legacy context, string refs) and re-runs ref callbacks.

Two precise details worth stating: the double render only happens in development so there is no production performance cost, and React suppresses console logs for the second invocation by default (configurable in DevTools), which is why people see one log and two network calls and get confused. Shipping with StrictMode enabled is standard practice in 2026 codebases and default in Next.js.

Key Points

  • Dev-only; compiled out of production builds entirely
  • Double-invokes render functions to catch impurity
  • Runs mount -> cleanup -> mount for effects to test teardown resilience
  • Double API call in dev signals a non-idempotent effect, not a React bug
  • Prepares components for <Activity> and other state-preserving remounts
Q16

How do you build reusable layout components with children and slot props instead of configuration props?

BasicComposition

Answer

Composition is React's answer to inheritance, and interviewers use it to see whether you design components as boxes that accept content or as prop-flag monsters. The children prop is whatever you nest between a component's tags; a Card that renders {children} inside its chrome can wrap anything without knowing what it wraps. When one hole is not enough, pass multiple elements as named props, the slots pattern: <PageShell header={<SearchBar />} sidebar={<Filters />} content={<Results />} />.

Because you pass elements, not data, the intermediate component never needs the props those elements consume, which eliminates most prop drilling. This composition also has a real performance property worth naming: when a component re-renders, the element objects it received as props (children included) are the same references as last render, so React can bail out of re-rendering that subtree. A parent holding rapidly-changing state that renders {children} passed from above does not re-render those children on each state change.

This is the 'children as props' optimisation and it often removes the need for memo entirely. The anti-pattern to contrast: a Button that grows booleans (isPrimary, isDanger, hasIcon, iconLeft, iconRight) until nobody can predict its output. Better: keep primitives small, compose variants (<Button variant='danger' icon={<TrashIcon />}>), or expose compound components (<Tabs><Tabs.List/><Tabs.Panel/></Tabs>) that share state via internal Context. Design-system teams at Flipkart-scale companies conduct entire interview rounds on exactly this trade-off.

// Slots: pass elements, not data
function PageShell({ header, sidebar, children }) {
  return (
    <div className='shell'>
      <header>{header}</header>
      <aside>{sidebar}</aside>
      <main>{children}</main>
    </div>
  );
}

// Usage: PageShell never learns about user or filters
<PageShell
  header={<SearchBar placeholder='Search jobs' />}
  sidebar={<Filters user={user} />}
>
  <Results query={query} />
</PageShell>;

// Compound components sharing state via Context
<Tabs defaultTab='profile'>
  <Tabs.List>
    <Tabs.Trigger id='profile'>Profile</Tabs.Trigger>
    <Tabs.Trigger id='security'>Security</Tabs.Trigger>
  </Tabs.List>
  <Tabs.Panel id='profile'><ProfileForm /></Tabs.Panel>
  <Tabs.Panel id='security'><SecurityForm /></Tabs.Panel>
</Tabs>;
Q17

Create React App is deprecated. How do you actually start and structure a React project in 2026?

BasicTooling

Answer

The React team deprecated Create React App in early 2025 and the official docs now point to two paths. For a client-rendered SPA, use Vite: npm create vite@latest my-app -- --template react-ts gives you esbuild-powered dev startup in milliseconds, Rollup (or the newer Rolldown) production builds, first-class TypeScript, and an ecosystem of plugins; pair it with React Router or TanStack Router for routing. For anything needing server rendering, SEO, or Server Components, start with a framework: Next.js (App Router) is the dominant choice, with React Router v7 in framework mode and TanStack Start as credible alternatives.

The honest decision rule interviewers want: dashboards, admin panels, and tools behind a login are fine as Vite SPAs; anything public-facing where search ranking, social previews, or first-paint speed on 4G matters should be server-rendered. Beyond scaffolding, a 2026-standard project includes: TypeScript by default (JS-only React roles are now rare in India), ESLint with eslint-plugin-react-hooks (the current major version ships React Compiler-powered rules that catch Rules-of-React violations), Prettier or Biome, Vitest plus React Testing Library for tests (Vitest has largely displaced Jest in Vite projects because it shares the same transform pipeline), and a folder convention organised by feature rather than by type: features/jobs/ containing its components, hooks, and api client together, instead of global components/ and hooks/ buckets that turn into junk drawers. Mentioning why CRA died, unmaintained webpack config, slow cold starts, no SSR story, shows you followed the ecosystem rather than memorising a scaffold command.

# SPA path
npm create vite@latest goodspace-dashboard -- --template react-ts
cd goodspace-dashboard && npm i && npm run dev

# Framework path (SSR, RSC, SEO)
npx create-next-app@latest goodspace-web --ts --app

# 2026 test stack for Vite projects
npm i -D vitest @testing-library/react @testing-library/user-event jsdom

# Feature-first structure
# src/features/jobs/{JobList.tsx,useJobSearch.ts,jobsApi.ts}
# src/features/auth/{LoginForm.tsx,useSession.ts}
# src/shared/{ui,lib,config}
Q18

How do React 19 form Actions work with useActionState and useFormStatus, and what do they replace?

BasicForms

Answer

Before React 19, a submit flow meant hand-rolling everything: preventDefault, an isSubmitting flag in useState, a try/catch storing an error in state, and a manual reset. Actions collapse that into a supported primitive. You pass an async function to <form action={fn}>; React calls it with the form's FormData, tracks its lifecycle inside a transition, and by default resets uncontrolled fields on success. useActionState(actionFn, initialState) wraps this: it returns [state, formAction, isPending], where your action receives (previousState, formData) and whatever it returns becomes the new state, typically validation errors or a success payload. isPending is true while the action runs, with no flag management. useFormStatus, imported from react-dom, lets a child component inside the form (a shared SubmitButton in your design system) read { pending, data, method } of the nearest enclosing form without any props, which is its whole point: the button stays decoupled from every form that uses it.

Details interviewers check: useFormStatus only works in a component rendered inside the <form>, not in the component that renders the form itself; actions run inside transitions, so the UI stays responsive and multiple submissions queue rather than clobber each other; and the same pattern scales up to Server Functions in Next.js, where the action string is a reference to code that executes on the server, giving you forms that work before JavaScript hydrates. The old experimental useFormState from react-dom is replaced by useActionState in the react package, a naming trap still common in stale tutorials.

import { useActionState } from 'react';
import { useFormStatus } from 'react-dom';

function SubmitButton() {
  const { pending } = useFormStatus(); // reads nearest form's status
  return (
    <button type='submit' disabled={pending}>
      {pending ? 'Applying...' : 'Apply for job'}
    </button>
  );
}

async function applyAction(prevState, formData) {
  const res = await api.apply({
    email: formData.get('email'),
    resumeId: formData.get('resumeId'),
  });
  if (!res.ok) return { error: res.message };
  return { error: null, applied: true };
}

function ApplyForm() {
  const [state, formAction, isPending] = useActionState(applyAction, {
    error: null,
  });
  return (
    <form action={formAction}>
      <input name='email' type='email' required />
      {state.error && <p role='alert'>{state.error}</p>}
      <SubmitButton />
    </form>
  );
}
Q19

How does useContext decide which components re-render when a Provider's value changes?

BasicState Management

Answer

createContext(defaultValue) creates a context object; a Provider (<Ctx value={...}> directly in React 19, or <Ctx.Provider> previously) supplies a value to the tree below it; useContext(Ctx), or use(Ctx) in React 19, reads the nearest Provider's value above the calling component. The re-render rule is precise and worth stating exactly: when the Provider's value changes by Object.is comparison, every component that reads that context re-renders, regardless of any memo() wrapper in between; components that merely sit between the Provider and the readers do not re-render because of the context change. The defaultValue is used only when a component reads the context with no Provider above it at all, not when a Provider passes undefined, another common misconception.

The classic performance bug follows directly from the Object.is rule: value={{ user, login, logout }} creates a fresh object every time the Provider's parent renders, so all consumers re-render even when nothing meaningful changed. Fixes: wrap the value in useMemo with proper dependencies, or hoist state so the provider component itself only re-renders when the context data truly changes. The second standard technique is splitting contexts: put fast-changing data and stable functions in separate Providers (UserContext and UserActionsContext) so a component that only needs logout does not re-render on every user object refresh. For 2026 completeness: React 19 removed the legacy contextTypes API, use(Context) can be called inside conditionals unlike useContext, and if you find yourself building selector-like behaviour on top of Context, that is the signal to move that state to Zustand or Jotai, which subscribe components to slices rather than to the whole value.

const SessionContext = createContext(null);
const SessionActionsContext = createContext(null);

function SessionProvider({ children }) {
  const [user, setUser] = useState(null);

  // Stable forever: actions never cause consumer re-renders
  const actions = useMemo(
    () => ({
      login: creds => api.login(creds).then(setUser),
      logout: () => setUser(null),
    }),
    []
  );

  return (
    <SessionContext value={user}>
      <SessionActionsContext value={actions}>
        {children}
      </SessionActionsContext>
    </SessionContext>
  );
}

// Re-renders only when user changes:
const user = useContext(SessionContext);
// Never re-renders from session changes:
const { logout } = useContext(SessionActionsContext);
Q20

Custom hooks versus higher-order components versus render props: how do you share logic between components in 2026?

BasicHooks

Answer

Custom hooks won. A custom hook is a function starting with use that calls other hooks; it shares stateful logic, not UI, and each call gets fully isolated state. useWindowSize, useDebouncedValue, useLocalStorage, useIntersectionObserver: each encapsulates a subscription or computation that previously required a wrapper component. The older patterns still appear in interviews because legacy codebases contain them.

A higher-order component is a function taking a component and returning an enhanced one (withRouter, Redux's old connect); its problems are wrapper hell in DevTools, prop-name collisions when stacking HOCs, and static typing pain. Render props pass a function as children (<Mouse>{pos => <Cursor at={pos} />}</Mouse>); more explicit than HOCs but nests badly. Hooks solve the same problems with plain function composition: values are ordinary variables, naming collisions disappear, and TypeScript inference is natural.

What interviewers actually assess: first, that you know the two Rules of Hooks (call only at the top level, never in conditions or loops; call only from components or other hooks) and why custom hooks must follow them, because React tracks hook state positionally per component instance. Second, that a custom hook calling useState does not share state between components; it shares logic, and two components calling useCart() have independent state unless the hook reads from Context or an external store. Third, taste: knowing when NOT to extract a hook.

A hook used once, wrapping a single useState, is indirection without value; extract when logic is reused, when an effect pairs with its cleanup and event wiring, or when a component's hook section becomes unreadable. HOCs retain one modern niche: cross-cutting wrappers applied at route level, like withAuthGuard.

// Custom hook: logic only, no UI, isolated state per caller
function useDebouncedValue(value, delayMs = 300) {
  const [debounced, setDebounced] = useState(value);

  useEffect(() => {
    const id = setTimeout(() => setDebounced(value), delayMs);
    return () => clearTimeout(id); // cancel on change/unmount
  }, [value, delayMs]);

  return debounced;
}

function JobSearch() {
  const [query, setQuery] = useState('');
  const debouncedQuery = useDebouncedValue(query, 400);
  const { data } = useJobResults(debouncedQuery); // fires per settled value

  return (
    <input value={query} onChange={e => setQuery(e.target.value)} />
  );
}
Q21

Why must state updates be immutable in React, and what does the classic mutation bug look like in a code review?

BasicState Management

Answer

React decides whether anything changed by comparing references, not by deep-inspecting objects. useState bails out when Object.is(oldValue, newValue) is true; memo and dependency arrays compare references shallowly. If you mutate an object or array in place and set it back, the reference is unchanged, so React concludes nothing happened: no re-render, or a memoised child that ignores a real change. The classic review bug: const next = items; next.push(newItem); setItems(next), or sorting in place with items.sort() before rendering (sort mutates; render must not).

The symptoms are maddeningly inconsistent, the UI updates sometimes, because some other state change eventually triggers a render that picks up the mutation, which is why interviewers love planting it in debugging rounds. Correct updates create new references along the changed path: spread for objects ({ ...user, city: 'Pune' }), map/filter/concat and the newer non-mutating array methods toSorted, toReversed, toSpliced for arrays, and nested spreads for deep updates. When nesting gets painful, Immer (bundled into Redux Toolkit's createSlice, or standalone via use-immer) lets you write draft.address.city = 'Pune' and produces the immutable result structurally sharing unchanged branches.

Two sharp follow-ups to be ready for: first, immutability is also what makes time-travel debugging, undo stacks, and change detection in DevTools possible, since every historical state remains intact. Second, only the changed path needs new references; unchanged siblings should keep their references precisely so memoised children can skip re-rendering. Cloning the entire tree deeply on every update (structuredClone everywhere) is its own anti-pattern, destroying those bailouts and garbage-collecting heavily.

// BUG: same reference, React sees 'no change'
function addSkillBroken(skill) {
  profile.skills.push(skill); // mutates state in place
  setProfile(profile); // Object.is passes -> no re-render
}

// Correct: new references along the changed path only
function addSkill(skill) {
  setProfile(prev => ({
    ...prev,
    skills: [...prev.skills, skill],
  }));
}

// Non-mutating array methods (ES2023) instead of sort/reverse
const ranked = candidates.toSorted((a, b) => b.score - a.score);

// Deep updates without spread pyramids: Immer
import { produce } from 'immer';
setProfile(prev =>
  produce(prev, draft => {
    draft.address.city = 'Pune';
  })
);
Q22

How do you fetch data inside useEffect without race conditions, and why is a raw fetch-in-effect no longer the recommended default?

BasicData Fetching

Answer

The race: a user types 'rea' then 'react'; two requests go out; the 'rea' response arrives last and overwrites the correct results. Any effect that fetches based on changing input has this bug unless each run either cancels its request or ignores its own stale response. Two standard fixes.

AbortController: create one per effect run, pass controller.signal to fetch, call controller.abort() in cleanup; the stale request rejects with an AbortError you deliberately swallow. Or the ignore flag: let ignore = false in the effect, check if (!ignore) before setting state, set ignore = true in cleanup. The AbortController version is strictly better in production because it actually cancels network work and drops server load, and axios supports the same signal option.

You should also handle loading and error as explicit states, reset error on new attempts, and never setState after unmount patterns via shared mutable flags across effects (a legacy anti-pattern). Then say the quiet part: in 2026 raw fetch-in-effect is a teaching pattern, not a production pattern. It gives you no caching (remounting refetches everything), no deduplication (two components fetching the same URL fire twice), no background revalidation, no retries, and StrictMode's double-mount exposes non-idempotent versions immediately.

Production answers: TanStack Query or SWR for SPAs, framework loaders (React Router loaders, Next.js server fetching in Server Components) when available. Interviewers frequently ask you to write the effect version correctly first, then discuss why you would not ship it; nailing both halves is the strong performance.

function useJobResults(query) {
  const [state, setState] = useState({ status: 'idle', data: null });

  useEffect(() => {
    if (!query) return;
    const controller = new AbortController();
    setState({ status: 'loading', data: null });

    fetch(`/api/jobs?q=${encodeURIComponent(query)}`, {
      signal: controller.signal,
    })
      .then(res => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return res.json();
      })
      .then(data => setState({ status: 'success', data }))
      .catch(err => {
        if (err.name === 'AbortError') return; // stale run, ignore
        setState({ status: 'error', data: null });
      });

    return () => controller.abort(); // cancel superseded request
  }, [query]);

  return state;
}
Q23

Why do error boundaries still require a class component, what do they not catch, and how does react-error-boundary help?

BasicError Handling

Answer

An error boundary is a component that catches errors thrown during rendering, in lifecycle methods, and in constructors of the tree below it, then renders a fallback instead of letting React unmount the entire root (which is what an uncaught render error does: a white screen). The catching mechanism exists only as two class lifecycle methods, static getDerivedStateFromError(error) to switch to fallback state, and componentDidCatch(error, info) for logging with the component stack, and there is still no hook equivalent even in React 19, making this the last mainstream reason to write a class. In practice nobody writes the class by hand: the react-error-boundary package wraps it with an ErrorBoundary component offering FallbackComponent, onError for reporting to Sentry, onReset plus resetKeys for retry flows, and a useErrorBoundary hook to surface async errors into the boundary manually.

What boundaries do NOT catch is the interview meat: event handler errors (use try/catch; the exception happens outside rendering), asynchronous code like setTimeout or rejected promises from fetch, errors in the boundary component itself, and server-side rendering errors (handled by the server framework). Placement is an architecture question: one root boundary as the last resort, plus granular boundaries around independent regions, each widget of a dashboard, each route, so a crashing recommendations panel does not blank the checkout flow. React 19 also improved error reporting: uncaught render errors go through onUncaughtError / onCaughtError options on createRoot instead of being re-thrown duplicated to the console, which is where you now hook global error telemetry.

import { ErrorBoundary, useErrorBoundary } from 'react-error-boundary';

function PanelFallback({ error, resetErrorBoundary }) {
  return (
    <div role='alert'>
      <p>This panel failed to load.</p>
      <button onClick={resetErrorBoundary}>Retry</button>
    </div>
  );
}

<ErrorBoundary
  FallbackComponent={PanelFallback}
  onError={(error, info) => sentry.captureException(error, info)}
  resetKeys={[userId]} // auto-reset when the user changes
>
  <RecommendationsPanel userId={userId} />
</ErrorBoundary>;

// Surfacing an async error into the nearest boundary
function Uploader() {
  const { showBoundary } = useErrorBoundary();
  async function upload(file) {
    try {
      await api.upload(file);
    } catch (err) {
      showBoundary(err); // async errors are not caught otherwise
    }
  }
}
Q24

Compare the styling approaches used in React codebases in 2026: CSS Modules, Tailwind, and runtime CSS-in-JS.

BasicStyling

Answer

Three families dominate, and the trade-off axis interviewers care about is where the styling cost is paid: build time or runtime. CSS Modules (Button.module.css imported as styles.button) scope class names by hashing at build time, work in every bundler including Vite and Next.js out of the box, cost nothing at runtime, and keep styles in real CSS with full tooling support; their weakness is dynamic styling, which you handle with CSS custom properties set via the style prop. Tailwind CSS applies utility classes directly in JSX; v4 moved to a CSS-first configuration (@theme in CSS rather than tailwind.config.js) with a much faster engine.

Its wins are colocated styling, a constrained design-token scale, and tiny production CSS since utilities are shared; the costs are unreadable class strings on complex components (mitigated with clsx and class-variance-authority for variant management) and a real learning curve for review discipline. Runtime CSS-in-JS, styled-components and Emotion, injects styles via JavaScript at runtime; it lost the ecosystem battle. Serialising styles during render costs performance, and, decisively, runtime injection is incompatible with React Server Components since there is no client runtime on the server-rendered portion. styled-components entered maintenance mode; teams migrated.

The RSC-compatible successors are zero-runtime or build-time extraction libraries: vanilla-extract (typed styles compiled to static CSS), Panda CSS, and StyleX (Meta's build-time atomic CSS). A good closing signal: on a fresh 2026 project the mainstream defaults are Tailwind for product velocity or CSS Modules for teams preferring plain CSS, with tokens expressed as CSS custom properties either way; runtime CSS-in-JS is now a migration liability question, not a green-field option.

Key Points

  • CSS Modules: build-time scoping, zero runtime, dynamic values via custom properties
  • Tailwind v4: CSS-first config, colocated utilities, cva/clsx for variants
  • Runtime CSS-in-JS is incompatible with Server Components; styled-components is in maintenance mode
  • RSC-safe alternatives: vanilla-extract, Panda CSS, StyleX (build-time extraction)
Q25

Walk through React's diffing heuristics: what happens when an element's type changes, and how do keys change the algorithm?

IntermediateRendering

Answer

A theoretically optimal tree diff is O(n^3); React gets to O(n) with two heuristics. First: if the element type at a position changes, React does not attempt to reconcile, it tears down the old subtree entirely (unmounting components, destroying state, running effect cleanups) and builds the new one from scratch. That applies to div -> span and equally to Card -> FancyCard, even if both render nearly identical DOM.

Same type means React keeps the Fiber and DOM node, updates only changed attributes for host elements, and re-renders with new props for components, preserving their state. Second: among siblings, keys establish identity. Without keys React matches children by position, so prepending an item makes every subsequent child look 'changed' and forces updates or remounts down the whole list; with stable keys React matches old and new children by key, moving DOM nodes instead of recreating them.

Two production consequences interviewers dig for. Defining a component inside another component's body creates a new function identity every render, so React sees a new type each time and remounts the subtree, resetting its state, one of the most common 'my input loses focus while typing' bugs; components must be defined at module scope. And conditional rendering position matters: {isAdmin && <AdminPanel />}{<Dashboard />} shifts Dashboard between child positions when isAdmin flips, so React may unmount and remount it; rendering null in the branch keeps positions stable. Finally, this heuristic is why key changes force remounts deliberately, and why 'same position, same type, state survives' explains most surprising state-preservation behaviour, like two conditional <Counter /> branches sharing state because they occupy the same slot.

Key Points

  • Type change at a position = full subtree teardown, state destroyed
  • Same type = Fiber and DOM reused, props updated, state preserved
  • Keys switch sibling matching from positional to identity-based
  • Components defined inside components remount every render (focus-loss bug)
  • Conditional branches at the same position with the same type share state
Q26

When do useMemo and useCallback actually improve performance, and how does the React Compiler change the calculus?

IntermediatePerformance

Answer

Both cache a value across renders and recompute it only when a dependency changes by Object.is: useMemo caches a computed result, useCallback caches the function itself (useCallback(fn, deps) is literally useMemo(() => fn, deps)). They help in exactly three situations. One: genuinely expensive computation, filtering or aggregating thousands of rows, running Fuse.js search, building a chart layout; measure first, because memoising a .map over 20 items costs more in bookkeeping than it saves.

Two: referential stability for props consumed by memo-wrapped children; passing a fresh inline callback defeats React.memo, so the callback must be useCallback-stable for the memoisation to hold. Three: referential stability for dependency arrays, when an object or function feeds useEffect deps, instability causes effect churn (a common cause of infinite fetch loops). Outside those cases they are noise: they add code, every dependency mistake introduces staleness bugs, and the cache is not a guarantee (React may discard memoised values under memory pressure or with future features, so correctness must never depend on memoisation).

The 2026 twist is the React Compiler: it analyses components that follow the Rules of React and inserts fine-grained memoisation automatically, at a granularity better than hand-written useMemo, making most manual memoisation redundant in compiler-enabled codebases (Meta runs it in production; it works with React 17+ via a runtime package). The strong interview answer: in a compiler-enabled codebase, write plain code and let the compiler memoise, keeping manual useMemo only for provably expensive computations; in legacy codebases, memoise along measured hot paths rather than defensively everywhere. Saying 'wrap everything in useCallback' is now a dated answer that costs points.

function CandidateTable({ candidates, onSelect }) {
  const [sortKey, setSortKey] = useState('score');

  // Worth memoising: real work over large data
  const sorted = useMemo(
    () => candidates.toSorted((a, b) => b[sortKey] - a[sortKey]),
    [candidates, sortKey]
  );

  // Worth memoising: keeps Row's memo() effective
  const handleSelect = useCallback(id => onSelect(id), [onSelect]);

  return sorted.map(c => (
    <Row key={c.id} candidate={c} onSelect={handleSelect} />
  ));
}

const Row = memo(function Row({ candidate, onSelect }) {
  return (
    <tr onClick={() => onSelect(candidate.id)}>
      <td>{candidate.name}</td>
      <td>{candidate.score}</td>
    </tr>
  );
});
💡 Pro Tip: If asked 'should everything be memoised', answer with the three legitimate cases, then mention the React Compiler making manual memoisation mostly obsolete. That one sentence dates your knowledge to 2026 rather than 2021.
Q27

How does React.memo decide whether to skip a re-render, and why does passing children usually break it?

IntermediatePerformance

Answer

memo(Component) returns a wrapper that, when its parent re-renders, shallowly compares the new props object against the previous one; if every prop is Object.is-equal, React skips re-rendering the component and reuses the last output. Three things defeat it in practice. Inline objects and arrays: style={{ margin: 8 }} or items={data.filter(...)} creates a new reference each parent render, so comparison always fails; hoist constants, memoise derived values.

Inline functions: onClick={() => save(id)} is a new function every time; stabilise with useCallback or restructure. And children: <MemoCard><p>Hello</p></MemoCard> passes children as a prop, and JSX creates a new element object every render, so children fails the shallow compare and the memo never skips. Workarounds: hoist static children into a constant element outside the component, or accept that memo is the wrong tool and use the composition bailout instead (a parent re-rendering with {children} received from above does not re-render those children, no memo needed). memo takes a second argument, a custom comparator (prev, next) => boolean returning true to SKIP rendering, a polarity opposite to shouldComponentUpdate that trips people up; use it sparingously because a wrong comparator causes stale UI, the worst class of bug.

Also be precise on what memo does not do: it does not stop re-renders from the component's own state, from context it consumes, or from a store subscription; it only gates parent-driven renders. And a skipped re-render is not free correctness: if the child reads mutable data outside props, memo will happily show stale output. With the React Compiler enabled, explicit memo wrappers become largely unnecessary since the compiler memoises component output automatically; in interviews, present memo as a targeted tool applied after profiling, typically to expensive rows inside long lists.

const JobCard = memo(function JobCard({ job, onApply }) {
  console.log('render', job.id); // profiling probe
  return (
    <article>
      <h3>{job.title}</h3>
      <button onClick={() => onApply(job.id)}>Apply</button>
    </article>
  );
});

function JobList({ jobs }) {
  const [query, setQuery] = useState('');
  const onApply = useCallback(id => api.apply(id), []);

  return (
    <>
      {/* typing here re-renders JobList, but memo + stable props
          keep every JobCard from re-rendering */}
      <input value={query} onChange={e => setQuery(e.target.value)} />
      {jobs.map(job => (
        <JobCard key={job.id} job={job} onApply={onApply} />
      ))}
    </>
  );
}
Q28

When does useReducer beat useState, and how do you structure a reducer for a non-trivial widget?

IntermediateHooks

Answer

useReducer(reducer, initialArg, init?) returns [state, dispatch]; you describe what happened by dispatching actions, and a pure reducer computes the next state. Reach for it over useState when: multiple pieces of state update together and must stay consistent (a form's values, errors, touched map, and submit status; a data widget's status, data, error triple); when the next state depends on the previous in non-trivial ways; when the same transitions fire from many places and you want them centralised and unit-testable; or when you keep writing several setX calls in a row inside one handler, the classic smell. Concrete advantages worth naming: dispatch has a stable identity forever, so it can be passed deep down or through Context without useCallback and never breaks memoised children; the reducer is a pure function testable without rendering anything (expect(reducer(state, action)).toEqual(next)); and all transition logic lives in one place, so illegal states become impossible to represent instead of merely unlikely.

Structure guidance interviewers listen for: model actions as events, not setters, { type: 'submit_failed', error } rather than { type: 'set_error' }, because event-shaped actions keep logic in the reducer instead of leaking it into components; use a discriminated union for action types in TypeScript so the switch is exhaustively checked; throw on unknown action types rather than silently returning state; and for deep state, wrap the reducer with Immer's produce (or useImmerReducer) to write mutable-style drafts. The useState-vs-useReducer boundary is judgement, not doctrine: two independent booleans are fine as useState; a five-field object with interdependent transitions wants a reducer. Mention that useReducer plus Context was the poor man's Redux for years, and that in 2026 genuinely global client state usually goes to Zustand instead, keeping useReducer for complex local state.

type State = {
  status: 'idle' | 'submitting' | 'error' | 'success';
  values: { email: string; otp: string };
  error: string | null;
};

type Action =
  | { type: 'field_changed'; field: 'email' | 'otp'; value: string }
  | { type: 'submitted' }
  | { type: 'submit_failed'; error: string }
  | { type: 'submit_succeeded' };

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case 'field_changed':
      return {
        ...state,
        error: null,
        values: { ...state.values, [action.field]: action.value },
      };
    case 'submitted':
      return { ...state, status: 'submitting', error: null };
    case 'submit_failed':
      return { ...state, status: 'error', error: action.error };
    case 'submit_succeeded':
      return { ...state, status: 'success' };
  }
}

const [state, dispatch] = useReducer(reducer, initialState);
// dispatch({ type: 'field_changed', field: 'otp', value: '482913' });
Q29

Why must hooks be called in the same order on every render, and what does React actually store per hook?

IntermediateHooks

Answer

Hooks have no names or IDs at runtime. React associates hook state with the component's Fiber as a linked list of hook records, and matches calls to records purely by call order: the first useState call gets the first record, the second gets the second, and so on. On the initial render React creates the list (memoizedState on the Fiber points to the head); on updates it walks it, handing each hook call the next record's stored state, update queue, or memoised deps.

This is why the Rules of Hooks exist: a hook inside an if, a loop, or after an early return can change the call sequence between renders, and every subsequent hook then reads the wrong record, your useState suddenly receives the memoised array of a neighbouring useMemo, producing the 'Rendered fewer hooks than expected' error or, worse, silently scrambled state. The eslint-plugin-react-hooks rules-of-hooks rule enforces this statically, and its current major version integrates React Compiler diagnostics that catch subtler violations. Precise details that elevate the answer: the rules apply per component instance, so different components ordering hooks differently is fine; early returns are legal as long as every hook call precedes them; custom hooks inherit the constraint because they are just functions inlined into the same sequence; and React 19's use() API is the deliberate exception, callable inside conditionals and loops, because it is implemented differently (it does not append to the positional list in the same way). Also explain the practical escape hatches: instead of conditionally calling a hook, call it unconditionally and branch inside (useEffect that returns early), or split the conditional UI into a child component that mounts conditionally, giving it its own Fiber and hook list.

// BROKEN: hook order differs between renders
function Profile({ userId }) {
  if (!userId) return <Login />; // OK only because no hook precedes it? No:
  const [tab, setTab] = useState('about'); // this hook is skipped when !userId
  // ...
}

// LEGAL: every hook runs before any early return
function ProfileFixed({ userId }) {
  const [tab, setTab] = useState('about');
  const user = useUser(userId); // custom hooks join the same sequence
  if (!userId) return <Login />;
  return <Tabs active={tab} onChange={setTab} user={user} />;
}

// LEGAL exception in React 19: use() may be conditional
function Comments({ promise, show }) {
  if (!show) return null;
  const comments = use(promise); // fine: use() is not order-bound
  return <List items={comments} />;
}
Q30

What is a stale closure in a React effect, and what problem does useEffectEvent solve that dependency arrays cannot?

IntermediateHooks

Answer

Every render creates fresh functions that close over that render's props and state. An effect that runs once ([] deps) but references a value keeps seeing the value from the render when it last ran: an interval logging count logs 0 forever, a WebSocket onMessage handler reads the initial theme, a keydown listener submits stale form data. The lint-driven fix, adding the value to the dependency array, is often wrong in a different way: the effect now tears down and re-runs on every change, reconnecting a socket on every keystroke of an unrelated input.

The underlying tension: dependencies conflate 'this value should re-trigger the synchronisation' with 'this value is merely read by it'. Pre-19.2 workarounds: the updater form for state (setCount(c => c + 1) needs no count dep), or the latest-ref pattern, mirror the value into a ref in one effect and read ref.current in the other, which works but is boilerplate that every codebase reinvents. useEffectEvent, added in React 19.2, is the sanctioned solution: const onTick = useEffectEvent(() => log(count, theme)) declares an Effect Event, a function that always sees the latest props and state but is NOT reactive, calling it from an effect does not require listing it (or what it reads) as dependencies. The effect keeps only its genuinely synchronising deps (the roomId for the connection), while incidental reads move into the event.

Constraints worth stating: Effect Events may only be called from inside effects (not passed to child components or event handler props), must not be listed in dependency arrays, and are not a general escape hatch for suppressing the exhaustive-deps rule on values that genuinely should re-trigger the effect. In interviews, walking through chat-room-connection example, roomId reactive, notification-sound preference read via useEffectEvent, is the canonical demonstration.

import { useEffect, useEffectEvent } from 'react';

function ChatRoom({ roomId, muted, theme }) {
  // Non-reactive reader: always sees latest muted/theme
  const onConnected = useEffectEvent(room => {
    if (!muted) playSound('connected.mp3');
    showToast(`Joined ${room}`, theme);
  });

  useEffect(() => {
    const conn = createConnection(roomId);
    conn.on('connected', () => onConnected(roomId));
    conn.connect();
    return () => conn.disconnect();
    // Only roomId re-triggers connection. Toggling mute or theme
    // no longer reconnects the socket.
  }, [roomId]);

  return <Messages roomId={roomId} />;
}
Q31

useLayoutEffect versus useEffect: when does the difference actually matter, and why does SSR warn about useLayoutEffect?

IntermediateHooks

Answer

Both run after React commits DOM mutations; the difference is paint timing. useLayoutEffect fires synchronously after mutations but BEFORE the browser paints, blocking paint until it finishes; useEffect is deferred until after paint. The difference matters in one scenario: your effect measures the DOM and immediately changes state or styles based on the measurement. Done in useEffect, the user sees one painted frame of the wrong UI before your correction lands, a visible flicker; done in useLayoutEffect, measure-and-adjust completes within the same frame and the user only ever sees the final result.

Canonical cases: positioning a tooltip or dropdown that must flip when it would overflow the viewport (render it hidden or at a guess, measure with getBoundingClientRect(), reposition before paint), restoring scroll position, or syncing scroll between panes. Everything else, data fetching, subscriptions, logging, timers, belongs in useEffect, because blocking paint with slow work directly degrades INP and makes the app feel janky; this is a performance question disguised as an API question. The SSR angle: neither effect runs on the server, but React warns specifically about useLayoutEffect in server-rendered components because the server sends HTML that the layout effect was supposed to adjust, guaranteeing a flash of incorrect content at hydration.

Remedies: move the logic to useEffect if post-paint is acceptable, gate the component behind mounted state so the adjusted version renders client-only, or in frameworks, dynamically import the component with SSR disabled. A precise closing detail: the commit-phase order is DOM mutations, then ref attachment, then useLayoutEffect, then paint, then useEffect; and useInsertionEffect (for CSS-in-JS libraries injecting styles) runs even before DOM mutations, a niche hook worth naming but never using in app code.

function Tooltip({ anchorRef, children }) {
  const tipRef = useRef(null);
  const [pos, setPos] = useState({ top: 0, left: 0 });

  useLayoutEffect(() => {
    const anchor = anchorRef.current.getBoundingClientRect();
    const tip = tipRef.current.getBoundingClientRect();

    // Flip above if it would overflow the bottom of the viewport
    const overflows = anchor.bottom + tip.height > window.innerHeight;
    setPos({
      top: overflows ? anchor.top - tip.height : anchor.bottom,
      left: anchor.left,
    });
    // Runs before paint: the user never sees the unflipped frame
  }, [anchorRef]);

  return createPortal(
    <div ref={tipRef} style={{ position: 'fixed', ...pos }}>
      {children}
    </div>,
    document.body
  );
}
Q32

How do loaders and actions in React Router v7 change data fetching compared to useEffect-based fetching in components?

IntermediateEcosystem

Answer

React Router v7 (which absorbed Remix) runs in two modes: library mode, the classic <Routes>/<Route> client routing, and framework mode, where routes are modules exporting a component plus data functions. The data primitives are the interview substance. A loader runs before the route renders: export async function loader({ params, request }) fetches what the route needs, and the component reads it with useLoaderData().

This inverts the useEffect pattern, render-then-fetch becomes fetch-then-render, which eliminates loading spinners for navigations (the router waits, or streams), kills the fetch waterfall where a parent renders, fetches, then children render and fetch, and centralises errors into route-level errorElement/ErrorBoundary exports. Loaders for a URL's matched routes run in parallel, parent and child data load simultaneously rather than sequentially. An action receives non-GET submissions: <Form method='post'> posts to the route's action, and after it completes the router automatically revalidates all active loaders, so the UI reflects the mutation without any manual cache invalidation, the same read-write-revalidate loop TanStack Query implements with invalidateQueries, but driven by navigation semantics.

Supporting APIs worth naming: useNavigation() exposes the pending navigation state for global progress bars and optimistic UI; useFetcher() submits to actions or calls loaders without navigating (inline like buttons, autosave); defer/Await (now streaming via returning promises) let a loader return fast data immediately and stream slow data into a <Suspense> boundary. In framework mode this all server-renders with streaming. The comparison answer interviewers want: component-fetching couples data to render timing and scatters cache logic; router loaders couple data to URLs, which matches how users navigate, while TanStack Query remains complementary for non-route-shaped data like polling, infinite scroll, and shared caches across routes.

// app/routes/jobs.$jobId.tsx (React Router v7 framework mode)
import { useLoaderData, Form, useNavigation } from 'react-router';

export async function loader({ params }) {
  const job = await db.jobs.findById(params.jobId);
  if (!job) throw new Response('Not Found', { status: 404 });
  return { job };
}

export async function action({ request, params }) {
  const formData = await request.formData();
  await db.applications.create({
    jobId: params.jobId,
    email: formData.get('email'),
  });
  return { ok: true };
  // All active loaders revalidate automatically after this
}

export default function JobDetail() {
  const { job } = useLoaderData();
  const navigation = useNavigation();
  return (
    <article>
      <h1>{job.title}</h1>
      <Form method='post'>
        <input name='email' type='email' required />
        <button disabled={navigation.state === 'submitting'}>Apply</button>
      </Form>
    </article>
  );
}
Q33

Zustand versus Redux Toolkit versus Jotai in 2026: how do their subscription models differ, and how do you choose?

IntermediateState Management

Answer

All three solve global client state, but their re-render mechanics differ, and that is what the question is really about. Redux Toolkit: one store, state changed only via dispatched actions through createSlice reducers (Immer built in, so 'mutating' draft syntax is safe), async via createAsyncThunk or RTK Query. Components subscribe with useSelector; after every dispatch, every subscribed selector re-runs, and the component re-renders only if the selected value's reference changed, which is why selecting fresh objects (state => ({ a, b })) without shallowEqual or createSelector memoisation is the classic RTK performance bug.

Its strengths: enforced unidirectional updates, superb DevTools time-travel, middleware, and RTK Query bundling a full server-cache. Costs: ceremony, Provider setup, and concepts (thunks, slices, selectors) that small apps do not need. Zustand: create a store hook with create((set, get) => ({ ... })); no Provider, no actions requirement, components subscribe to slices via selectors: useStore(s => s.cart.items).

Renders trigger only when the selected slice changes, middleware (persist, devtools, immer, subscribeWithSelector) is opt-in, and the store is readable and writable outside React (getState/setState), handy for interceptors and sockets. It became the community default for new SPAs because it is a few KB and nearly zero boilerplate. Jotai: bottom-up atoms; components subscribe to individual atoms, and derived atoms recompute automatically from their dependency graph, excellent for fine-grained interdependent state (spreadsheet-like UIs, complex filter panels).

Choosing: server data goes to TanStack Query regardless, which shrinks 'global state' to UI concerns (auth session, cart, modals, preferences); for that, Zustand is the pragmatic default, Redux Toolkit when a large team wants enforced structure and auditability (fintechs like Razorpay and Zerodha value the action log), Jotai when state is naturally graph-shaped. Saying 'Context is enough for everything' or 'always Redux' both fail; the sequencing answer wins.

// Zustand: no Provider, selector-based subscriptions
import { create } from 'zustand';
import { persist } from 'zustand/middleware';

const useCartStore = create(
  persist(
    (set, get) => ({
      items: [],
      add: item =>
        set(state => ({ items: [...state.items, item] })),
      remove: id =>
        set(state => ({
          items: state.items.filter(i => i.id !== id),
        })),
      total: () =>
        get().items.reduce((sum, i) => sum + i.price, 0),
    }),
    { name: 'cart' } // localStorage persistence
  )
);

// Re-renders ONLY when items.length changes:
function CartBadge() {
  const count = useCartStore(s => s.items.length);
  return <span className='badge'>{count}</span>;
}

// Outside React (e.g. axios interceptor):
// useCartStore.getState().remove(staleId);
Q34

In TanStack Query, what do staleTime and gcTime control, and how does invalidation drive UI updates after a mutation?

IntermediateData Fetching

Answer

TanStack Query (formerly React Query) treats server data as a cache keyed by queryKey, and the two timers govern its lifecycle. staleTime (default 0) is how long fetched data is considered fresh: while fresh, mounting components and window refocus serve the cache with no network request; once stale, the cache is still served instantly but a background refetch fires (stale-while-revalidate), triggered by mount, refocus, reconnect, or interval. gcTime (default 5 minutes, renamed from cacheTime in v5) is how long inactive data, data with zero subscribed components, stays in memory before garbage collection; return within it and you get an instant render plus background refresh, after it, a hard loading state. The default staleTime of 0 surprises teams with 'why so many requests': every refocus refetches everything; the fix is setting staleTime per query to match data volatility (job listings maybe 30 seconds, a static skills taxonomy hours). Mutations complete the loop: useMutation performs the write, and in onSuccess (or better, onSettled) you call queryClient.invalidateQueries({ queryKey: ['jobs'] }), which marks matching queries stale and immediately refetches the active ones, so every component showing that data updates without any manual state threading.

Key matching is hierarchical: invalidating ['jobs'] hits ['jobs', { city: 'Pune' }] too, which is why keys should be structured arrays, not concatenated strings. Alternatives to invalidation worth naming: setQueryData for direct cache writes when the mutation response contains the updated entity (saves a round trip), and the onMutate optimistic-update flow, snapshot the cache, write the optimistic value, roll back in onError. The conceptual point interviewers want: this entire machinery, deduplication, retries with backoff, background refresh, pagination via useInfiniteQuery, is why server state does not belong in Redux or useEffect: those give you a place to put data, not a cache policy.

const queryClient = useQueryClient();

const jobsQuery = useQuery({
  queryKey: ['jobs', { city, role }],
  queryFn: () => api.searchJobs({ city, role }),
  staleTime: 30_000, // fresh for 30s: no refetch storms on refocus
  gcTime: 5 * 60_000, // keep inactive cache for 5 min
});

const applyMutation = useMutation({
  mutationFn: jobId => api.apply(jobId),
  onSuccess: (updatedJob, jobId) => {
    // Option A: surgical cache write, no extra request
    queryClient.setQueryData(['job', jobId], updatedJob);
    // Option B: mark stale + refetch everything matching
    queryClient.invalidateQueries({ queryKey: ['jobs'] });
  },
});

// <button onClick={() => applyMutation.mutate(job.id)}
//         disabled={applyMutation.isPending}>Apply</button>
Q35

How does Suspense actually work for data fetching, and what does the use() API change in React 19?

IntermediateConcurrency

Answer

Suspense is a coordination protocol. When a component cannot render because its data is not ready, it suspends, mechanically, something in its render throws a thenable (a promise). React catches it, walks up to the nearest <Suspense fallback={...}> boundary, renders the fallback there, and attaches a continuation to the promise; when it resolves, React retries rendering the suspended subtree.

The component never sees isLoading; it is written as if data is always available, and the loading UI moves out of the component into boundary placement, which becomes a design decision: one page-level boundary gives a single spinner, nested boundaries let the shell appear while widgets stream in. Before React 19 you never threw promises yourself; you used Suspense-enabled sources (React.lazy, frameworks, TanStack Query's useSuspenseQuery). React 19 made the pattern first-class with use(promise): it reads a promise during render, suspending until resolution and rethrowing rejection to the nearest error boundary.

Critically, use() may be called conditionally and in loops, unlike hooks. The trap interviewers test: you cannot create the promise inside the component that calls use() on it, rendering would create a fresh promise each attempt, suspending forever in a loop. The promise must be cached or created outside the suspending component: passed down from a Server Component (the flagship RSC pattern: server starts the fetch, client component unwraps it), held in a cache like TanStack Query's, or memoised.

Also pair Suspense with error boundaries, fallback handles pending, the boundary handles rejection, and know the retry-vs-throttle behaviour: React may reveal content in a single pass and briefly hold fallbacks to avoid flicker. Mentioning useSuspenseQuery as the production-grade way to get suspense semantics with caching shows applied, not just theoretical, knowledge.

// Server Component (framework): start fetch, do NOT await
export default function JobPage({ params }) {
  const reviewsPromise = fetchReviews(params.jobId); // kicked off early
  return (
    <ErrorBoundary FallbackComponent={ReviewsError}>
      <Suspense fallback={<ReviewsSkeleton />}>
        <Reviews reviewsPromise={reviewsPromise} />
      </Suspense>
    </ErrorBoundary>
  );
}

// Client Component: unwrap with use()
'use client';
import { use } from 'react';

function Reviews({ reviewsPromise }) {
  const reviews = use(reviewsPromise); // suspends until resolved
  return (
    <ul>
      {reviews.map(r => (
        <li key={r.id}>{r.text}</li>
      ))}
    </ul>
  );
}
Q36

How do React.lazy and route-based code splitting work, and what are the production pitfalls (flash of fallback, chunk load errors)?

IntermediatePerformance

Answer

Bundlers split at dynamic import() boundaries: import('./AdminPanel') becomes a separate chunk fetched on demand. React.lazy(() => import('./AdminPanel')) wraps that in a component: first render suspends while the chunk downloads, so a <Suspense> boundary above supplies the fallback, and subsequent renders are instant. The highest-leverage split is by route, users on /jobs should not download the recruiter dashboard, so you lazy() each route component; framework routers (Next.js, React Router v7 framework mode) do this automatically per route file.

Second-tier splits: heavy below-the-fold widgets, chart libraries, rich text editors, PDF viewers, anything large and conditionally shown. Production pitfalls are where this question separates candidates. Flash of fallback: on fast connections the spinner appears for 50 ms and vanishes, looking like a glitch; React 18+ mitigates this for transitions, navigation wrapped in startTransition keeps the current screen visible instead of showing the fallback.

Chunk load failures: after you deploy, hashed chunk filenames change; a user with the old HTML clicks a route and their browser 404s on the dead chunk, throwing 'Failed to fetch dynamically imported module' (or ChunkLoadError under webpack). Handle it in an error boundary that offers or performs a reload, and keep previous-build assets available on the CDN for a grace window. Waterfalls: lazy component loads, then its data fetch starts; fix by preloading, kick off the import() and the data request together on hover or on navigation intent (routers expose loader+lazy pairing for exactly this). Also mention named-export friction (lazy expects a default export; re-export or map the module), and measuring with source-map-explorer or rollup-plugin-visualizer to find what is actually worth splitting: splitting tiny components adds request overhead for nothing.

import { lazy, Suspense, startTransition } from 'react';

const RecruiterDashboard = lazy(() => import('./RecruiterDashboard'));

// Preload on intent: import() starts before the click lands
const preload = () => import('./RecruiterDashboard');

function App() {
  const [view, setView] = useState('jobs');
  return (
    <>
      <nav>
        <button
          onMouseEnter={preload}
          onClick={() =>
            startTransition(() => setView('dashboard')) // no fallback flash
          }
        >
          Dashboard
        </button>
      </nav>
      <ChunkErrorBoundary onChunkError={() => window.location.reload()}>
        <Suspense fallback={<PageSkeleton />}>
          {view === 'dashboard' ? <RecruiterDashboard /> : <JobList />}
        </Suspense>
      </ChunkErrorBoundary>
    </>
  );
}
Q37

What does concurrent rendering actually mean in React, and what specifically does useTransition change about an update?

IntermediateConcurrency

Answer

Concurrent rendering means React can prepare a render without being committed to finishing it in one synchronous block: work happens in interruptible units, and React can pause mid-render to service something more urgent, discard a half-done render whose inputs changed, or work on a low-priority render in the background while the committed UI stays interactive. It is not parallelism, everything still runs on one thread, it is preemptive scheduling of render work. The mechanism is priority lanes: urgent updates (typing, clicks, anything the user must see reflected immediately) versus transitions (non-urgent recomputation of what the screen shows next).

By default every setState is urgent. useTransition gives you [isPending, startTransition]; wrapping a state update in startTransition(() => setResults(bigFilter(query))) marks it as a transition: React keeps the current UI fully responsive, renders the new tree in the background at low priority, and if another keystroke arrives, abandons the stale background render and starts over with the latest value, no torn intermediate states ever commit. isPending lets you show a subtle busy indicator over the old content instead of unmounting it. Concrete uses: heavy filtered lists where each keystroke recomputes thousands of rows (input stays urgent, list recompute is the transition), tab switches rendering expensive panels, and navigation, routers wrap navigation in transitions so an already-visible page does not collapse into a Suspense fallback while the next one suspends. React 19 extended transitions to async functions (Actions): await inside startTransition works, with the pending state spanning the async work, which is what powers form Actions. Two clarifiers that impress: transitions do not make the render function itself faster, they change when it runs and what it blocks (compute inside components is still your problem); and updates inside a transition batch with other transitions but never delay urgent updates, which is the formal fix for the 'typing feels laggy because the list re-renders' class of bug.

function CandidateSearch({ allCandidates }) {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState(allCandidates);
  const [isPending, startTransition] = useTransition();

  function handleChange(e) {
    const next = e.target.value;
    setQuery(next); // urgent: the input must echo instantly
    startTransition(() => {
      // low priority: interruptible, abandoned if user keeps typing
      setResults(expensiveFilter(allCandidates, next));
    });
  }

  return (
    <>
      <input value={query} onChange={handleChange} />
      <div style={{ opacity: isPending ? 0.6 : 1 }}>
        <ResultsList results={results} />
      </div>
    </>
  );
}
Q38

useDeferredValue versus debouncing: what is the difference in mechanism, and when is each the right tool?

IntermediateConcurrency

Answer

They look interchangeable, both make an expensive consumer lag behind a fast-changing input, but the mechanisms differ fundamentally. Debouncing is time-based and sits upstream of React: you delay updating state until input pauses for N milliseconds (via setTimeout or a useDebouncedValue hook). Nothing renders during the wait, and the delay is fixed regardless of device speed: a fast machine still waits the full 300 ms, a slow machine may still choke when the update finally lands. useDeferredValue(value) is priority-based and lives inside React's scheduler: it returns the previous value while a low-priority background re-render with the new value is in progress; consumers of the deferred value render at transition priority, interruptible and abandonable when newer input arrives.

There is no fixed delay, on a fast device the deferred render catches up almost instantly, on a slow one it lags exactly as long as needed, and the urgent part of the UI (the input echoing keystrokes) never blocks. The standard pattern pairs it with memo: the expensive list takes deferredQuery and is memo-wrapped, so the urgent render (new query, old deferredQuery) skips re-rendering the list entirely, and only the background render recomputes it. You can detect staleness with isStale = query !== deferredQuery to dim old results.

When to choose which: useDeferredValue when the cost is rendering, your own React tree is expensive to recompute; debouncing when the cost is off-tree, network requests or server load, because deferring does not reduce how many requests you fire, while a 300 ms debounce collapses eight keystrokes into one API call. They compose: debounce the API call, defer the rendering of results. React 19 also added an initialValue option to useDeferredValue for the first render. Bonus nuance: useDeferredValue only helps if the deferred consumer is actually skippable (memoised); without memo, the urgent render still pays for the list and you have gained nothing.

function FilterableList({ items }) {
  const [query, setQuery] = useState('');
  const deferredQuery = useDeferredValue(query);
  const isStale = query !== deferredQuery;

  return (
    <>
      <input
        value={query}
        onChange={e => setQuery(e.target.value)} // always instant
      />
      <div style={{ opacity: isStale ? 0.5 : 1 }}>
        <HeavyList items={items} query={deferredQuery} />
      </div>
    </>
  );
}

// memo is what lets the urgent render skip the heavy work
const HeavyList = memo(function HeavyList({ items, query }) {
  const filtered = expensiveFilter(items, query);
  return filtered.map(i => <Row key={i.id} item={i} />);
});
Q39

How does useOptimistic implement optimistic UI in React 19, and how does it roll back on failure?

IntermediateConcurrency

Answer

Optimistic UI shows the expected result of an async action immediately, a liked heart fills in, a sent message appears, and reconciles when the server responds. Hand-rolled versions are error-prone: you mutate local state, remember how to undo it, handle overlapping actions, and fight race conditions. useOptimistic(actualState, updateFn) formalises it: it returns [optimisticState, addOptimistic]. While no action is pending, optimisticState equals actualState.

When you call addOptimistic(input) inside a transition or action, React computes updateFn(currentState, input) and renders that value; the moment the async action settles and the real state updates (or the action fails), React re-renders from the actual source of truth, so rollback is automatic, there is no undo code, because the optimistic value was never written into real state, it is an overlay that evaporates. Requirements interviewers probe: addOptimistic must be called inside a transition or an Action (form action, startTransition); calling it outside logs a warning and the overlay is immediately discarded. Multiple rapid actions compose: each pending action's optimistic update stacks over the base state, and as each settles, the overlay shrinks, which handles the send-three-chat-messages-quickly case that manual implementations get wrong.

The classic example: a message list where the optimistic entry renders with a 'sending' flag; when the server confirms and the parent's real message list updates (via revalidation or state), the overlay disappears and the confirmed message stands. Design points worth stating: keep updateFn pure (it may run repeatedly); mark optimistic items visually (opacity, clock icon) and disable actions on them (you cannot delete a message that has no server id yet); and pair failures with a toast plus retry rather than silently vanishing UI, users must learn their action failed. This hook plus useActionState and useFormStatus forms React 19's Actions toolkit, replacing three ad hoc patterns with one lifecycle.

'use client';
import { useOptimistic, useRef } from 'react';

function Thread({ messages, sendMessage }) {
  const formRef = useRef(null);
  const [optimisticMessages, addOptimistic] = useOptimistic(
    messages,
    (current, newText) => [
      ...current,
      { id: `tmp-${Date.now()}`, text: newText, sending: true },
    ]
  );

  async function submit(formData) {
    const text = formData.get('text');
    formRef.current.reset();
    addOptimistic(text); // renders instantly with sending: true
    await sendMessage(text); // real messages prop updates after this
    // On failure, React re-renders from `messages`: auto-rollback
  }

  return (
    <>
      {optimisticMessages.map(m => (
        <p key={m.id} style={{ opacity: m.sending ? 0.5 : 1 }}>
          {m.text} {m.sending && <small>Sending...</small>}
        </p>
      ))}
      <form action={submit} ref={formRef}>
        <input name='text' required />
      </form>
    </>
  );
}
Q40

A page re-renders far too often and feels sluggish. Walk through how you diagnose it with React DevTools Profiler.

IntermediatePerformance

Answer

First reproduce and record: open React DevTools, Profiler tab, enable 'Record why each component rendered' in settings, start recording, perform the janky interaction, stop. The flamegraph shows each commit; select the slow ones (the commit bar chart ranks them by duration). For every component in a commit you see render duration and the render reason: props changed (and which ones), state changed (which hook), context changed, or parent rendered.

The diagnosis usually falls into a handful of patterns. A wide, shallow flame where hundreds of rows rendered because their parent did: the fix is memoising rows or, better, moving the changing state down so it does not live in the list's parent (state colocation), or passing children through composition. Props reported as changed with values that look identical: a referential-identity problem, inline objects, arrays, or functions recreated each render; confirm by expanding the changed prop, then stabilise with useCallback/useMemo or hoist.

Context changed shown across scattered components: a Provider value being recreated; memoise the value or split the context. State changed firing unexpectedly often: search for setState inside render or an effect updating state that the effect depends on, the classic render loop. If DevTools alone is not enough, add temporary probes: the <Profiler id onRender> component logs actual vs base durations programmatically; console.count in suspicious components counts renders cheaply.

For deeper CPU analysis, record the same interaction in the browser Performance panel with CPU throttling at 4x-6x, mid-range Android is the median Indian user, and check whether time is in rendering (fix re-renders) or in your own JS (fix the algorithm; no memo will save an O(n^2) filter). Two closing disciplines: measure in production mode, development render times are inflated and StrictMode double-renders; and after each fix, re-record the same interaction to prove the commit count or duration dropped, never claim a performance fix without before/after numbers.

Key Points

  • Profiler flamegraph + 'record why each component rendered' is the entry point
  • Parent-driven cascades: colocate state down or memoise the wide subtree
  • 'Props changed' with identical-looking values = referential identity bug
  • Render loops: setState during render, or effects feeding their own deps
  • Verify on a 4x CPU throttle and in production build; re-measure after fixing
Q41

What is React Testing Library's guiding principle, and how do you choose between getByRole, findBy, and queryBy queries?

IntermediateTesting

Answer

RTL's principle: test what the user experiences, not how the component is implemented. You render real components into jsdom (or a browser via Vitest browser mode), find elements the way a user or screen reader would, interact, and assert on visible outcomes. No reaching into state, no asserting 'setState was called', no shallow rendering, all of which made Enzyme tests break on refactors that changed nothing user-visible.

The query API encodes this. Priority order for selectors: getByRole first ('button', { name: /apply/i }), because it doubles as an accessibility check, if getByRole cannot find your button, a screen reader user probably cannot either; then getByLabelText for form fields, getByPlaceholderText, getByText, getByDisplayValue, and only as a last resort getByTestId with data-testid, which proves nothing about usability. The three prefixes answer different questions: getBy* returns the element or throws immediately (element must exist now); queryBy* returns null instead of throwing (the only correct way to assert absence: expect(queryByText('Error')).not.toBeInTheDocument()); findBy* returns a promise that retries for ~1000 ms (element will appear after async work, await screen.findByText('Saved')).

The *AllBy variants return arrays. For interactions, use userEvent (v14 setup pattern: const user = userEvent.setup()) over fireEvent: user.type fires the full keydown/keypress/input sequence and respects disabled elements, while fireEvent.change teleports a value in one synthetic event and can pass tests for inputs a real user cannot operate. For async UI, await findBy or waitFor(() => expect(...)); never assert immediately after an awaited action resolves internal promises. With MSW (Mock Service Worker) intercepting network calls at the request level rather than mocking fetch or axios internals, this stack, Vitest + RTL + userEvent + MSW + jest-dom matchers like toBeInTheDocument and toBeDisabled, is the standard 2026 answer for how a React codebase is tested.

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

test('applying to a job shows confirmation', async () => {
  const user = userEvent.setup();
  render(<JobCard job={job} />); // MSW intercepts the POST

  // Role queries double as accessibility assertions
  const applyBtn = screen.getByRole('button', { name: /apply/i });
  await user.click(applyBtn);

  // findBy*: waits for async UI (retries up to ~1s)
  expect(await screen.findByText(/application sent/i)).toBeInTheDocument();

  // queryBy*: the ONLY way to assert absence without throwing
  expect(screen.queryByRole('alert')).not.toBeInTheDocument();
  expect(applyBtn).toBeDisabled();
});
Q42

How do you test a custom hook with renderHook, and what does the 'not wrapped in act(...)' warning actually mean?

IntermediateTesting

Answer

Hooks cannot run outside a component, so @testing-library/react provides renderHook(() => useMyHook(args)), which mounts a throwaway component that calls the hook and exposes { result, rerender, unmount }. result.current is the hook's latest return value; crucially it is a live getter, so you re-read result.current after each action rather than destructuring it once (destructuring captures a stale snapshot, a classic test bug). State-changing operations are wrapped in act(): act(() => { result.current.increment(); }), which tells React to process the update queue and flush effects before your assertions, so you assert against settled state rather than a half-processed intermediate. rerender(newProps) tests how the hook responds to changing inputs (does useDebouncedValue restart its timer?), and unmount() verifies cleanup, assert intervals are cleared or subscriptions removed by spying on clearInterval or the unsubscribe function. For hooks needing context (a hook reading QueryClientProvider or your AuthProvider), pass a wrapper: renderHook(useCart, { wrapper: ({ children }) => <CartProvider>{children}</CartProvider> }).

The act warning: 'An update to Component was not wrapped in act(...)' means a state update happened outside the code paths the testing library already wraps, almost always an async continuation, a resolved fetch, a timer, a subscription callback, that fired after your test's synchronous block ended. It is a real signal, not noise: your assertions may run before the update, and the test can flake. Fixes in order of preference: await the async result with await waitFor(() => expect(result.current.status).toBe('success')) or findBy queries (both are act-aware), use await act(async () => { ... }) around code that resolves promises, and for fake timers, advance them inside act (act(() => { vi.advanceTimersByTime(500); })).

Never silence the warning globally. Testing-philosophy closer: only extract-and-test hooks with meaningful logic; a hook that wraps one useState is better covered through its consuming component's behavioural tests.

import { renderHook, act, waitFor } from '@testing-library/react';
import { vi } from 'vitest';

test('useDebouncedValue settles after the delay', () => {
  vi.useFakeTimers();
  const { result, rerender } = renderHook(
    ({ value }) => useDebouncedValue(value, 400),
    { initialProps: { value: 'r' } }
  );

  rerender({ value: 'react' });
  expect(result.current).toBe('r'); // not yet

  act(() => {
    vi.advanceTimersByTime(400); // flush inside act
  });
  expect(result.current).toBe('react');
  vi.useRealTimers();
});

test('useJobResults loads', async () => {
  const { result } = renderHook(() => useJobResults('react'), {
    wrapper: QueryWrapper, // provides QueryClientProvider
  });
  await waitFor(() => expect(result.current.status).toBe('success'));
});
Q43

How do you type React components properly in TypeScript: props, children, event handlers, useRef, and generic components?

IntermediateTypeScript

Answer

The 2026 conventions are settled. Props: define an interface or type and annotate the function parameter directly, function Button({ variant, onClick }: ButtonProps). React.FC has fallen out of favour: it added an implicit children to every component (fixed in React 18 types but the habit stuck), complicates generics, and buys nothing over plain annotation.

Children: type explicitly as children: React.ReactNode (anything renderable); use ReactElement only when you require an element specifically, and () => ReactNode for render-prop APIs. Event handlers: use React's typed handlers, onChange: React.ChangeEvent<HTMLInputElement>, onClick: React.MouseEvent<HTMLButtonElement>, onSubmit: React.FormEvent<HTMLFormElement>, or the handler-type shorthand React.ChangeEventHandler<HTMLInputElement>; typing the element parameter correctly is what gives you e.target.value without casts. useRef has two typing modes: DOM refs as useRef<HTMLInputElement>(null) (read-only .current managed by React), mutable containers as useRef<number>(0) or useRef<AbortController | null>(null). useState infers from the initial value; supply the union explicitly when it starts empty: useState<Candidate | null>(null) or useState<Status>('idle'). To make a component accept all native props of an element (a design-system Button forwarding aria-*, disabled, type), extend React.ComponentPropsWithoutRef<'button'> and spread the rest; with React 19's ref-as-prop, ComponentPropsWithRef or just declaring ref?: React.Ref<HTMLButtonElement> replaces the old forwardRef generic gymnastics.

Generic components type collections: function Select<T>({ options, getLabel, onPick }: SelectProps<T>) preserves the option type through to the callback, arrow-function generics in .tsx files need the trailing-comma trick (<T,>) to disambiguate from JSX. Discriminated union props encode invariants, a Button that is either href+anchor or onClick+button, so invalid combinations fail to compile. Interviewers increasingly ask candidates to type a real component live; fluency here is a hiring signal because typed props are the API documentation of a component library.

import { type ComponentPropsWithoutRef, type ReactNode, useRef, useState } from 'react';

// Extends native button props: consumers pass aria-*, disabled, etc.
interface ButtonProps extends ComponentPropsWithoutRef<'button'> {
  variant?: 'primary' | 'ghost';
  children: ReactNode;
}

function Button({ variant = 'primary', children, ...rest }: ButtonProps) {
  return (
    <button className={`btn btn-${variant}`} {...rest}>
      {children}
    </button>
  );
}

// Generic component: T flows from options to the callback
interface SelectProps<T> {
  options: T[];
  getLabel: (option: T) => string;
  onPick: (option: T) => void;
}

function Select<T>({ options, getLabel, onPick }: SelectProps<T>) {
  const [open, setOpen] = useState(false);
  const rootRef = useRef<HTMLDivElement>(null);
  return (
    <div ref={rootRef}>
      {open &&
        options.map((o, i) => (
          <div key={i} onClick={() => onPick(o)}>
            {getLabel(o)}
          </div>
        ))}
    </div>
  );
}
Q44

React 19 made ref a normal prop. What does that change about forwardRef, and when do you still need useImperativeHandle?

IntermediateHooks

Answer

Historically, ref was not a real prop: React extracted it before your component saw props, so a function component could not receive a ref without being wrapped in forwardRef((props, ref) => ...), an API awkward enough to generate a decade of confusion, especially with TypeScript generics and DevTools naming. React 19 removes the special case for function components: ref arrives in props like anything else, function Input({ ref, ...props }) just works, and forwardRef is deprecated (still functional for migration, with a codemod available, but new code should not use it; class components are unchanged since their refs point to instances). Two related 19 upgrades: ref callbacks can return a cleanup function, ref={node => { observer.observe(node); return () => observer.disconnect(); }}, replacing the old convention where React called your callback with null on unmount; and TypeScript types simplify to ref?: React.Ref<HTMLInputElement> inside your own props type. useImperativeHandle(ref, createHandle, deps) remains relevant and unchanged in purpose: it lets a component expose a curated imperative API instead of the raw DOM node.

Use it when a parent must trigger behaviour that is genuinely imperative and the child wants encapsulation: a VideoPlayer exposing { play(), pause(), seekTo(s) } without handing out the <video> element; a form section exposing { validate(), focusFirstError() }; a virtualised list exposing { scrollToIndex(i) }. The design guidance interviewers listen for: imperative handles are an escape hatch, if a parent uses one to set data or toggle visibility, that should be props and state instead; reserve handles for focus, scrolling, media playback, selection, and animation triggering, the operations that have no good declarative encoding. Restrict the surface deliberately, exposing only named methods keeps the child free to restructure its DOM without breaking parents, which is the entire point of not just forwarding the node.

// React 19: ref is a plain prop, no forwardRef
function SearchInput({ ref, ...props }) {
  return <input ref={ref} type='search' {...props} />;
}

// Curated imperative surface with useImperativeHandle
function OtpSection({ ref }) {
  const inputRef = useRef(null);

  useImperativeHandle(ref, () => ({
    focusFirstError() {
      inputRef.current?.focus();
      inputRef.current?.select();
    },
    clear() {
      inputRef.current.value = '';
    },
    // The raw <input> node is deliberately NOT exposed
  }), []);

  return <input ref={inputRef} inputMode='numeric' maxLength={6} />;
}

// Parent
const otpRef = useRef(null);
// otpRef.current.focusFirstError();

// 19-style ref callback with cleanup
<div ref={node => {
  resizeObserver.observe(node);
  return () => resizeObserver.unobserve(node);
}} />;
Q45

Build an accessible modal in React: what do you need beyond rendering a portal, and how do you manage focus correctly?

IntermediateAccessibility

Answer

Rendering into a portal is maybe a tenth of a correct modal; the rest is focus and semantics, and accessibility rounds at product companies go straight here. Requirements: the container needs role='dialog' (or role='alertdialog' for confirmations), aria-modal='true', and a label, aria-labelledby pointing at the title element. On open, focus must move into the dialog, to the first focusable element or the dialog itself with tabIndex={-1}; store the previously focused element (document.activeElement) in a ref first, and on close return focus to it, otherwise keyboard users are dumped at the top of the document, the single most common audit failure.

While open, Tab must be trapped: keydown on Tab at the last focusable element wraps to the first, Shift+Tab at the first wraps to the last, implemented by querying focusable descendants or, far more robustly, using focus-trap-react or a headless component (Radix UI Dialog, React Aria's useDialog, Headless UI) that also handles edge cases like focus escaping into browser chrome. Escape closes; clicking the backdrop closes (with the click-target check so clicks inside the panel do not); the background should be inert, modern browsers support the inert attribute on the app root, which removes it from both tab order and the accessibility tree, superseding manual aria-hidden bookkeeping. Prevent background scroll (overflow: hidden on body, remembering scrollbar-width compensation to stop layout shift).

The 2026 twist: the native <dialog> element with showModal() provides top-layer rendering, focus trapping, Escape handling, and ::backdrop for free, and React 19 handles the open state and events cleanly, so 'why not just <dialog>' is now a fair interviewer follow-up; the honest answer is that it covers most needs, with libraries still adding animation orchestration, nested-dialog policies, and consistent cross-browser inert behaviour. Recommending an audited headless primitive over hand-rolling, while being able to explain everything it does, is the senior answer.

Key Points

  • role='dialog', aria-modal='true', aria-labelledby on the container
  • Move focus in on open; restore document.activeElement on close
  • Trap Tab / Shift+Tab; Escape and backdrop-click close
  • Make the background inert (inert attribute) and lock body scroll
  • Native <dialog>.showModal() or Radix/React Aria beat hand-rolled traps
Q46

What causes 'Hydration failed because the initial UI does not match what was rendered on the server', and how do you fix each class of cause?

IntermediateSSR

Answer

Hydration is React rendering your components in the browser and attaching listeners to the server-sent HTML, assuming both renders produce identical output. When they differ, React logs the mismatch error; in React 18+, a mismatch does not attempt patch-up: React discards the server DOM for that boundary and client-renders it from scratch, so you pay double work and risk visible flicker, and in React 19 the error message diffs the mismatching HTML for you instead of the old cryptic warnings. Causes and fixes, in the order they occur in real codebases.

Non-deterministic render values: Date.now(), new Date() formatting with the server in UTC and the client in IST (a constant bug for Indian teams), Math.random(), locale-dependent toLocaleString(); fix by making the value deterministic (pass the server's timestamp as a prop, format with an explicit fixed locale and timeZone), or render placeholder content and fill in the client-only value after mount. Browser-only API reads during render: window.innerWidth, localStorage-driven theming, matchMedia; the server cannot know these, so gate on a mounted flag (render the neutral variant first, update in useEffect) or restructure so the value comes from a cookie the server can also read (the standard dark-mode fix). Invalid HTML nesting: <div> inside <p>, <p> inside <p>, a <tr> outside <table>; browsers repair invalid markup while parsing, so the DOM no longer matches React's expectations, and the fix is writing valid HTML.

Third-party interference: browser extensions injecting DOM before hydration (you cannot fix, only detect), and A/B testing scripts mutating HTML, move them post-hydration. User-specific rendering: showing 'Hi Saksham' from a client-side token while the server rendered the logged-out header; solve with cookie-based auth the server reads, or defer personalisation to after mount. The targeted escape hatch is suppressHydrationWarning on the specific element (legitimate for timestamps), never a blanket wrapper. What interviewers want beyond the list: you know hydration mismatches are a correctness AND performance problem, and that 'useEffect + mounted state' is the general-purpose fix precisely because effects only run on the client.

Key Points

  • Mismatch = boundary is client re-rendered from scratch (double work, flicker)
  • Time/locale/random values: make deterministic or render after mount
  • window/localStorage reads: mounted-flag gate or move the signal into a cookie
  • Invalid HTML nesting breaks parsing and guarantees mismatches
  • suppressHydrationWarning is per-element, for genuinely variable text like times
Q47

When does a long list need virtualization, and how does a windowing library like TanStack Virtual actually work?

IntermediatePerformance

Answer

Rendering 10,000 job rows creates 10,000+ component instances and several times that many DOM nodes: mounting takes seconds, every re-render walks a huge tree, scrolling triggers style/layout over an enormous layout tree, and memory balloons. Virtualization (windowing) renders only the visible slice plus an overscan buffer, maybe 20-30 rows, inside a container that fakes the full scroll height, recycling rows as the user scrolls. Mechanics, which the interviewer wants concretely: an outer scroll container with overflow: auto; an inner spacer element given the estimated total height (itemCount x itemSize for fixed heights) so the scrollbar is honest; on scroll, compute the visible index range from scrollTop, and absolutely position (or translate) only those rows at their offsets.

TanStack Virtual (the headless successor in spirit to react-window/react-virtualized) exposes exactly this: useVirtualizer({ count, getScrollElement, estimateSize, overscan }) returns getTotalSize() for the spacer and getVirtualItems(), each with an index and start offset you apply via transform: translateY. It handles dynamic row heights by measuring rendered rows (measureElement) and correcting estimates, the hard problem that fixed-size libraries dodge, and supports horizontal lists, grids, and scrollToIndex for chat-style UIs. Costs you must volunteer: Ctrl+F browser find fails because off-screen rows do not exist; accessibility needs care (announce list size with aria-rowcount, keep focus management sane when the focused row is recycled); SEO renders only the window server-side (usually irrelevant for logged-in lists); and rapid scrolling shows blank gaps if overscan is too small or rows render slowly.

Decision rule: virtualize when profiling shows list rendering as the bottleneck, typically somewhere past a few hundred non-trivial rows; before that, pagination or infinite scroll with reasonable page sizes is simpler. And infinite scroll composes with virtualization: an IntersectionObserver near the list end triggers fetchNextPage from useInfiniteQuery while the virtualizer keeps the DOM small.

import { useVirtualizer } from '@tanstack/react-virtual';

function CandidateList({ candidates }) {
  const parentRef = useRef(null);
  const virtualizer = useVirtualizer({
    count: candidates.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 72, // px per row (estimated)
    overscan: 8, // extra rows above/below the viewport
  });

  return (
    <div ref={parentRef} style={{ height: 600, overflow: 'auto' }}>
      <div
        style={{
          height: virtualizer.getTotalSize(), // honest scrollbar
          position: 'relative',
        }}
      >
        {virtualizer.getVirtualItems().map(vRow => (
          <div
            key={candidates[vRow.index].id}
            ref={virtualizer.measureElement} // dynamic height support
            data-index={vRow.index}
            style={{
              position: 'absolute',
              top: 0,
              width: '100%',
              transform: `translateY(${vRow.start}px)`,
            }}
          >
            <CandidateRow candidate={candidates[vRow.index]} />
          </div>
        ))}
      </div>
    </div>
  );
}
Q48

What problem does useId solve, and why is Math.random() or a counter the wrong way to generate element IDs in React?

IntermediateSSR

Answer

Accessible markup needs stable ID relationships: a label's htmlFor pointing at an input's id, aria-describedby pointing at an error message, aria-labelledby wiring dialog titles. Hardcoding an id inside a reusable component breaks the moment the component renders twice on one page, duplicate IDs make label clicks and screen readers target the wrong element. Generating IDs with Math.random() or an incrementing module counter breaks differently and worse under SSR: the server generates one value into the HTML, the client generates another during hydration, and you get a hydration mismatch on every instance of the component; counters also diverge because the server and client may render components in different orders or counts (Suspense, selective hydration, StrictMode double-rendering). useId() solves exactly this: it produces an identifier derived from the component's structural position in the tree, not from randomness or call order, so the server and client compute the same string for the same component instance.

The format is deliberately unusual (colon-wrapped, like ':r1:', ':R2kb:') to avoid colliding with user IDs, which also means it is not usable in CSS selectors or querySelector without escaping, an intentional nudge. Usage pattern: call it once per component and derive multiple related IDs by suffixing (id + '-input', id + '-error', id + '-hint') rather than calling useId per element; every instance of the component gets a distinct base. The negative space is equally tested: useId is NOT for list keys, keys must come from data identity, and generating them per render defeats reconciliation entirely; and it is not for anything needing meaning or persistence, it is opaque and can change between builds.

If multiple independent React roots render on one page (micro-frontends), the identifierPrefix option on createRoot/hydrateRoot namespaces the IDs so roots do not collide. A crisp closing line: useId exists because 'unique, deterministic, and identical on server and client' is impossible with ad hoc generation once concurrent and streaming rendering reorder work.

function FormField({ label, error, hint, ...inputProps }) {
  const id = useId(); // same value on server and client
  const inputId = `${id}-input`;
  const errorId = `${id}-error`;
  const hintId = `${id}-hint`;

  return (
    <div>
      <label htmlFor={inputId}>{label}</label>
      <input
        id={inputId}
        aria-invalid={!!error}
        aria-describedby={
          [error && errorId, hint && hintId].filter(Boolean).join(' ') ||
          undefined
        }
        {...inputProps}
      />
      {hint && <p id={hintId}>{hint}</p>}
      {error && (
        <p id={errorId} role='alert'>
          {error}
        </p>
      )}
    </div>
  );
}
// Two <FormField/>s on one page: distinct, hydration-safe ID trios.
Q49

Explain the Fiber architecture: what is a fiber node, how does the work loop process them, and what are lanes?

AdvancedInternals

Answer

Fiber, the reconciler rewrite shipped in React 16, exists to make rendering interruptible. Pre-Fiber, reconciliation was a recursive tree walk on the call stack: once started, it ran to completion, and a large tree meant a long, unbreakable main-thread block. Fiber reifies that stack into data: each fiber is a plain JS object representing a unit of work for one component or host element, holding its type, pendingProps and memoizedProps, memoizedState (for function components, the head of the hooks linked list), an effect flags bitmask, and pointers, child, sibling, return, that turn the tree into a linked structure walkable iteratively.

Because traversal state lives in these objects rather than the native call stack, React can process one fiber, check whether it should yield (the scheduler slices work into roughly 5 ms chunks and yields to keep the main thread responsive), and later resume exactly where it stopped. React maintains two trees: current (what is committed on screen) and workInProgress (being built), with alternate pointers linking them; finishing a render just swaps the root pointer, double buffering, so users never observe a partially updated tree, and an abandoned workInProgress costs nothing visible. The work loop has two phases: the interruptible render phase (beginWork walks down creating/updating fibers, completeWork walks back up collecting effects) and the synchronous commit phase (apply DOM mutations, swap trees, run layout effects), commit is never interruptible, which is why committed UI is always consistent.

Lanes are the priority model: a 31-bit bitmask where each lane class encodes urgency, SyncLane for discrete events like clicks, InputContinuousLane for scroll/drag, DefaultLane, TransitionLanes for startTransition work, and idle lanes. Bitmask representation lets React batch compatible lanes in one render, work on high-priority lanes first while parking others, and prevent starvation by expiring long-deferred lanes into synchronous work. This is the machinery that makes useTransition, useDeferredValue, Suspense, and selective hydration possible, they are all lane assignments.

Key Points

  • A fiber = unit-of-work object; child/sibling/return pointers replace the call stack
  • current vs workInProgress trees with alternate pointers: double buffering
  • Render phase is interruptible (5ms slices); commit phase is synchronous
  • Lanes: 31-bit priority bitmask; sync > continuous input > default > transition > idle
  • Transitions and Suspense are lane mechanics, not separate systems
Q50

How do React Server Components differ from SSR, what can and cannot cross the 'use client' boundary, and what problems do RSC actually solve?

AdvancedServer Components

Answer

SSR and RSC answer different questions. SSR renders your component tree to HTML for the first paint, then ships the full component JavaScript anyway and hydrates it; every component's code reaches the browser. Server Components execute ONLY on the server: their code is never bundled to the client, and what crosses the wire is not HTML but the RSC payload, a serialised description of their rendered output with slots where Client Components go.

Consequences: an RSC can read the database or filesystem directly, hold API keys, and import a heavy markdown renderer or syntax highlighter at zero bundle cost; it cannot use state, effects, or browser APIs, and it never re-renders on the client, its output is static until the server produces a new payload. Client Components (files opening with the 'use client' directive) are the interactive leaves: hooks, event handlers, browser APIs, and they still SSR to HTML on first load, a persistent misconception, 'use client' does not mean client-only rendering, it means hydrated and bundled. Boundary rules interviewers test hard: 'use client' marks a module-graph boundary, everything a client module imports becomes client code, so the directive is not needed on every interactive file, only at entry points into interactivity.

Props crossing server-to-client must be serialisable by React's protocol: JSON-ish values, promises (enabling the pass-promise-then-use() streaming pattern), but not functions (except Server Functions marked 'use server'), class instances, or Dates-as-anything-but-values. A Server Component cannot be imported by a Client Component, but can be passed INTO one as children or props, the composition pattern that lets an interactive shell wrap server-rendered content. What RSC solves: bundle size (rendering logic and data-layer code stays on the server), data access without client waterfalls (components await their own queries co-located with markup, composing on the server), and security (secrets never serialised).

Costs: mental-model complexity, framework dependence (Next.js App Router is the mainstream implementation; React Router v7 has RSC support), and a serialisation boundary that becomes your architecture. In 2026 interviews at companies on Next.js, drawing this boundary correctly in a design exercise is often the whole question.

// app/jobs/[id]/page.tsx: Server Component (default in Next.js App Router)
import { db } from '@/lib/db'; // server-only import, never bundled
import { ApplyButton } from './ApplyButton';

export default async function JobPage({ params }) {
  const job = await db.job.findUnique({ where: { id: params.id } });
  return (
    <article>
      <h1>{job.title}</h1>
      {/* Server content composed INSIDE a client component via children */}
      <ApplyButton jobId={job.id}>
        <SalaryBreakdown salary={job.salary} /> {/* stays a server comp */}
      </ApplyButton>
    </article>
  );
}

// app/jobs/[id]/ApplyButton.tsx: interactivity boundary
'use client';
import { useState } from 'react';

export function ApplyButton({ jobId, children }) {
  const [open, setOpen] = useState(false);
  return (
    <>
      <button onClick={() => setOpen(o => !o)}>Apply</button>
      {open && children}
    </>
  );
}
Q51

How do Server Functions ('use server') work end to end, and how do they enable forms that work before hydration?

AdvancedServer Components

Answer

A Server Function is a function marked with the 'use server' directive (at the top of its body, or at the top of a file to mark all exports) that runs exclusively on the server but is callable from client code as if imported. The mechanics: at build time, the bundler replaces the client-side import with a typed reference (an ID plus metadata); calling it from the browser serialises the arguments, POSTs them to the framework's endpoint, executes the real function server-side, and returns the serialised result, RPC with the plumbing generated for you. Terminology shifted with React 19: 'Server Functions' is the umbrella; when passed to a form action or called as a mutation they are 'Server Actions'.

The form integration is the headline: <form action={serverFn}> works BEFORE JavaScript loads, because the framework renders a real HTML form that posts to the endpoint; after hydration, React upgrades it to a fetch-based submission with useActionState managing returned state, pending flags via useFormStatus, and useOptimistic overlays. That progressive enhancement matters on slow networks, a user on a 3G connection in a tier-2 city can submit the application form during the seconds where JS has not hydrated, and interviewers at companies serving Bharat-scale traffic notice when candidates connect the feature to that reality. The security model is where advanced candidates differentiate: every Server Function is a PUBLIC HTTP endpoint.

The compiler does not authenticate anything; you must validate and authorise inside each function, check the session, validate inputs with zod, enforce ownership of the mutated resource, exactly as you would an Express route. Common vulnerabilities: trusting a userId argument from the client instead of deriving it from the session; forgetting rate limiting on expensive actions; returning more data than the caller should see (everything returned is serialised to the client). After a mutation in Next.js you call revalidatePath or revalidateTag so cached RSC payloads refresh. Closing nuance: Server Functions execute sequentially per submission and integrate with transitions, so isPending spans the full round trip.

// app/actions/applications.ts
'use server';
import { z } from 'zod';
import { getSession } from '@/lib/auth';
import { revalidatePath } from 'next/cache';

const ApplySchema = z.object({
  jobId: z.string().uuid(),
  coverNote: z.string().max(2000),
});

export async function applyToJob(prevState, formData) {
  const session = await getSession(); // NEVER trust client-sent identity
  if (!session) return { error: 'Please sign in' };

  const parsed = ApplySchema.safeParse({
    jobId: formData.get('jobId'),
    coverNote: formData.get('coverNote'),
  });
  if (!parsed.success) return { error: 'Invalid input' };

  await db.application.create({
    data: { ...parsed.data, userId: session.userId },
  });
  revalidatePath('/applications');
  return { error: null, ok: true };
}

// Client: works pre-hydration as a plain HTML form post
// const [state, formAction] = useActionState(applyToJob, { error: null });
// <form action={formAction}>...</form>
Q52

What does the React Compiler actually do to your code, what are its correctness preconditions, and how does it change day-to-day React development?

AdvancedTooling

Answer

The React Compiler (formerly 'React Forget', stable 1.0 in late 2025) is a build-time optimising compiler, a Babel/SWC plugin, that automatically memoises components and hooks. It analyses each function, understands which values each piece of output depends on, and rewrites the component to cache JSX subtrees, computed values, and callbacks in a per-instance memo cache (backed by an internal hook), invalidating each cached slot only when its actual inputs change. The granularity beats hand-written memoisation: where a human wraps a whole component in memo() or a whole computation in useMemo, the compiler memoises individual expressions and JSX branches independently, so a change to one prop re-computes only the slots that read it.

Its precondition is the Rules of React: components and hooks must be pure during render, props/state immutable, hooks called unconditionally at the top level. The compiler statically detects violations, and when it cannot prove a function follows the rules it skips optimising that function rather than risk changing behaviour, so adoption is safe-by-default and incremental (a file-by-file opt-in or opt-out via 'use no memo'). Diagnostics ship through eslint-plugin-react-hooks, whose compiler-powered rules now flag mutation-during-render, ref reads in render, and dependency mistakes far more precisely than the old heuristics.

What changes in practice: new code largely stops writing useMemo, useCallback, and memo, deleting a whole category of dependency-array bugs and review debates; performance stops depending on individual discipline; and stale-closure bugs shrink because caching is computed from true data flow rather than hand-maintained arrays. What it does NOT do: it cannot fix an expensive algorithm (an O(n^2) filter is still your problem), it does not memoise across component boundaries where rules are broken, it does not replace the memoisation semantics of useEffect dependency correctness, and code that lied to React (mutating state, abusing refs during render) may need fixing before the compiler will touch it. Meta runs it across Facebook and Instagram production. Interview positioning: describe manual memoisation as a legacy optimisation layer that the compiler subsumes, while showing you still understand the underlying referential-equality model, because you will maintain pre-compiler codebases for years.

Key Points

  • Build-time plugin; memoises values, callbacks, and JSX slots per dependency
  • Skips any function it cannot prove follows the Rules of React (safe by default)
  • eslint-plugin-react-hooks now carries compiler-powered diagnostics
  • Removes most manual useMemo/useCallback/memo in new code
  • Does not fix slow algorithms or rule-breaking legacy code; opt-out via 'use no memo'
Q53

A long-lived React SPA's memory grows over hours until the tab crashes. How do you find and fix the leaks?

AdvancedProduction

Answer

Confirm, locate, then fix. Confirm with the browser Task Manager (Shift+Esc in Chrome) or the Performance panel's memory track over a scripted usage loop: navigate between the two main screens fifty times; if the sawtooth baseline climbs after GC, you leak. Locate with DevTools Memory panel using the three-snapshot technique: snapshot, perform the suspected interaction cycle (open and close the modal, mount and unmount the route), force GC, snapshot again, and diff, filtering to objects allocated between snapshots that survived.

Detached DOM nodes in the diff are the smoking gun: DOM subtrees unmounted by React but pinned by a JS reference. The React-specific leak catalogue, which is what the interviewer is really asking for: (1) Subscriptions without cleanup, socket.on, RxJS subscribe, store.subscribe, browser addEventListener on window/document inside useEffect with no returned cleanup; each mount stacks another listener, and each listener's closure pins that render's entire scope, including big props. (2) Timers: setInterval never cleared keeps its callback and closure alive forever; same for recursive setTimeout chains. (3) Observers: IntersectionObserver/ResizeObserver observed nodes without disconnect() in cleanup. (4) Out-of-React caches: module-level Maps memoising by object key grow unboundedly, use WeakMap or an LRU; unbounded TanStack Query caches are bounded by gcTime, but a gcTime: Infinity plus dynamic query keys (a key per keystroke) is a real leak pattern. (5) Globals set from components: window.currentUser = user, or analytics SDKs handed component closures. (6) Closures captured by long-lived promises: an await that never settles keeps the whole async function frame alive. Fixes are mechanical once located, every effect that subscribes returns an unsubscribe; AbortController aborts in-flight work on unmount; observers disconnect; caches get eviction.

Then regression-proof it: an E2E memory test in CI (Playwright looping the flow and asserting heap growth stays under a threshold) and a MemLab run for the critical flows. Mentioning that StrictMode's mount-cleanup-mount cycle exists precisely to smoke out missing cleanups ties the answer back to fundamentals.

Key Points

  • Three-snapshot heap diff; detached DOM nodes are the smoking gun
  • Usual suspects: uncleaned listeners, intervals, observers, module-level caches
  • A leaked listener pins its closure: one small bug retains megabytes of props
  • WeakMap/LRU for caches; AbortController for in-flight async
  • Automate: Playwright heap assertions or MemLab in CI; StrictMode catches missing cleanups early
Q54

What is useSyncExternalStore for, what is 'tearing' in concurrent rendering, and how would you wire a store to it correctly?

AdvancedInternals

Answer

Tearing: under concurrent rendering, React can pause mid-render and resume later. If components read a mutable external source (a module-level store, window.innerWidth, a WebSocket-fed cache) directly during render, the source can change during the pause, so components rendered before the pause show value A while those after show value B, one committed frame displaying two different versions of the same state. React state is immune because React snapshots it per render; external stores are not, hence useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?).

Contract: subscribe(callback) registers the store-change callback and returns an unsubscribe function, React resubscribes if the subscribe function's identity changes, so define it stably; getSnapshot() returns the current value and MUST return a cached reference until the store actually changes, because React calls it repeatedly (including during render) and uses Object.is to detect change; returning a fresh object each call ({ ...state }) causes an infinite render loop, the number one implementation bug. When notified, React checks whether the snapshot changed and re-renders subscribers; critically, if the store mutates during a concurrent render, React detects the snapshot moved and restarts the render synchronously, guaranteeing every component in a commit sees the same snapshot: consistency is the feature, achieved by de-prioritising interruptibility for external-store updates (which is also why store-driven updates cannot be time-sliced like transitions, a real trade-off worth naming). getServerSnapshot serves SSR and hydration: it must return the same value on server and client to avoid hydration mismatches, typically a static default. Real usages: Zustand, Redux's React bindings, TanStack Query, and Jotai all sit on this hook internally; and it is the correct primitive for subscribing to browser APIs, online status via navigator.onLine with online/offline events, matchMedia for responsive logic, localStorage cross-tab sync via the storage event. Interviewers often ask you to write useOnlineStatus with it live; the version below is the shape they expect, including the SSR snapshot.

import { useSyncExternalStore } from 'react';

// Subscribing to a browser API safely (tear-free, SSR-safe)
function subscribe(callback) {
  window.addEventListener('online', callback);
  window.addEventListener('offline', callback);
  return () => {
    window.removeEventListener('online', callback);
    window.removeEventListener('offline', callback);
  };
}

export function useOnlineStatus() {
  return useSyncExternalStore(
    subscribe,
    () => navigator.onLine, // client snapshot (primitive: no caching issue)
    () => true // server snapshot: assume online during SSR
  );
}

// Minimal external store: getSnapshot returns a CACHED reference
function createStore(initial) {
  let state = initial;
  const listeners = new Set();
  return {
    getSnapshot: () => state, // same ref until set() runs
    set(partial) {
      state = { ...state, ...partial }; // new ref on real change
      listeners.forEach(l => l());
    },
    subscribe(l) {
      listeners.add(l);
      return () => listeners.delete(l);
    },
  };
}
Q55

How does streaming SSR with renderToPipeableStream and selective hydration work, and what does it improve over renderToString?

AdvancedSSR

Answer

renderToString is synchronous and all-or-nothing: the server cannot flush a byte until the slowest data dependency resolves, the client then downloads all HTML, then all JS, then hydrates the entire tree top-down in one pass; a slow recommendations query blocks the whole page, and nothing is interactive until everything hydrates. React 18's renderToPipeableStream (Node streams; renderToReadableStream for Edge/Web-streams runtimes) breaks all three walls using Suspense boundaries as the unit of streaming. Out-of-order streaming: the server immediately flushes the shell, everything outside pending Suspense boundaries, with fallbacks in place; as each suspended subtree's data resolves, the server streams an HTML chunk plus a tiny inline script that swaps the chunk into the fallback's slot, so HTML arrives in completion order, not document order, over one connection.

Selective hydration: the client hydrates boundary by boundary as code and HTML become available instead of one monolithic pass; if the user clicks inside a not-yet-hydrated boundary, React records the event, prioritises hydrating that boundary (lane priorities again), and replays the interaction, so user attention literally reorders hydration work. The API callbacks encode the streaming decisions: onShellReady is where you set status/headers and pipe (start streaming as soon as the shell is done, the usual choice); onShellError means the shell itself failed, send a fallback page or 500; onAllReady waits for everything, which is what you use for crawlers when you want complete HTML; onError logs per-boundary errors that will be retried client-side. Practical implications interviewers push on: Suspense boundary placement becomes a performance design tool (each boundary is a streaming seam and a hydration unit); status codes and headers must be decided by shell time, because streaming starts before slow content settles; a slow component with no boundary above it silently degrades you back to block-everything behaviour; and this machinery is what Next.js App Router exposes as loading.tsx files and per-component streaming.

RSC layers on top: the RSC payload streams the same way, with client boundaries hydrating selectively. The one-line summary that lands: renderToString couples TTFB to the slowest query; streaming SSR couples it to the fastest useful shell.

// server.tsx (Node)
import { renderToPipeableStream } from 'react-dom/server';

app.get('*', (req, res) => {
  const { pipe, abort } = renderToPipeableStream(
    <App url={req.url} />,
    {
      bootstrapScripts: ['/assets/main.js'],
      onShellReady() {
        // Shell done: fallbacks in place for pending boundaries
        res.statusCode = 200;
        res.setHeader('Content-Type', 'text/html');
        pipe(res); // start streaming NOW
      },
      onShellError(err) {
        res.statusCode = 500;
        res.send('<!doctype html><p>Something went wrong</p>');
      },
      onError(err) {
        console.error('boundary error', err); // retried on client
      },
    }
  );
  setTimeout(abort, 10_000); // stop streaming on pathological queries
});

// App: each Suspense boundary = a streaming seam + hydration unit
// <Suspense fallback={<RecsSkeleton />}><Recommendations /></Suspense>
Q56

What does the <Activity> component in React 19.2 do, and which long-standing problems does it solve?

AdvancedVersions

Answer

<Activity mode='visible' | 'hidden'> lets you keep UI mounted-but-dormant instead of choosing between the two old options: conditional unmounting ({show && <Panel />}), which destroys all state, scroll positions, and in-progress form input; or CSS hiding (display: none), which preserves state but keeps effects live, subscriptions running, timers firing, and the hidden tree still participating in renders at full priority. In hidden mode, Activity visually hides its children AND unmounts their effects (cleanups run, sockets close, intervals clear) while preserving component state and DOM: switch back to visible and state is exactly where the user left it, with effects re-mounting. Rendering of hidden activities happens at background priority, React works on them only when idle, so keeping several dormant screens around does not compete with the visible UI.

The problems this solves are ones every SPA team has hacked around: tab UIs where switching away and back loses half-filled forms (teams kept all tabs mounted with display:none and paid the effect cost); back navigation that should restore the previous screen's scroll and state instantly (the old ecosystem answer was react-router scroll restoration plus state-lifting gymnastics); and pre-rendering the likely next screen so navigation feels instant, render it hidden, let React prepare it in the background, flip to visible on navigation. This is also why StrictMode simulates the mount-cleanup-remount cycle: components whose effects cannot survive being torn down and re-run break under Activity, so effect hygiene is the compatibility precondition. Details worth naming in an interview: hidden activities do not appear to screen readers or the tab order (unlike naive opacity hiding); inputs keep their DOM so uncontrolled form state survives too; and the React team has signalled future modes beyond visible/hidden (offscreen pre-rendering tied into View Transitions work). It is not a cache, if the component unmounts entirely, state is still gone, and holding many heavy hidden trees trades memory for responsiveness, so it complements rather than replaces router-level solutions.

import { Activity, useState } from 'react';

function RecruiterWorkspace() {
  const [tab, setTab] = useState('pipeline');
  const tabs = ['pipeline', 'messages', 'analytics'];

  return (
    <>
      <nav>
        {tabs.map(t => (
          <button key={t} onClick={() => setTab(t)}>
            {t}
          </button>
        ))}
      </nav>

      {/* All three stay mounted; hidden ones keep state (draft
          messages, scroll, filters) but their effects unmount and
          they render only at background priority. */}
      <Activity mode={tab === 'pipeline' ? 'visible' : 'hidden'}>
        <PipelineBoard />
      </Activity>
      <Activity mode={tab === 'messages' ? 'visible' : 'hidden'}>
        <MessageThreads /> {/* socket closes while hidden */}
      </Activity>
      <Activity mode={tab === 'analytics' ? 'visible' : 'hidden'}>
        <AnalyticsDashboard />
      </Activity>
    </>
  );
}
Q57

Where does XSS actually get into a React app despite JSX escaping, and how do you use dangerouslySetInnerHTML safely?

AdvancedSecurity

Answer

JSX auto-escapes interpolated values: {userInput} renders < and > as entities, so the naive script-tag injection fails. The real attack surface is everywhere that escaping does not apply, and security-conscious interviewers (fintech especially) walk you through each. First, dangerouslySetInnerHTML: any HTML from users or third parties, rich-text CVs, CMS content, markdown rendered to HTML, chat messages, must be sanitised server-side or at render with DOMPurify (DOMPurify.sanitize(html), optionally with an allowlist config); 'we escape on input' is insufficient because data enters systems through many doors.

Second, URL-based injection: <a href={userSuppliedUrl}> with javascript:alert(document.cookie) executes on click. React logs a warning for javascript: URLs but does not block them, so you must validate protocols, parse with new URL() and allow only http:, https:, mailto:. The same applies to iframe src and form action values derived from data.

Third, JSON-in-HTML: serialising state into a script tag for hydration (window.__STATE__ = JSON.stringify(data)) is injectable via </script> sequences in the data; escape the angle bracket or use serialize-javascript. Fourth, spreading untrusted objects: {...props} from an API response can smuggle dangerouslySetInnerHTML itself as a prop, an underrated vector, never spread objects you do not control into elements. Fifth, refs and third-party DOM: anything doing manual innerHTML in an effect or a jQuery-era integration bypasses React entirely.

Defence in depth beyond code: a strict Content-Security-Policy (script-src with nonces, no unsafe-inline) so injected markup cannot execute even when a sanitisation gap exists; HttpOnly cookies so tokens survive an XSS (localStorage tokens are readable by any injected script, which is why the localStorage-vs-cookie question is really an XSS question); Trusted Types where supported to make sink assignment fail closed. Framework note: server-rendered apps must also treat RSC payloads and hydration data as injection surfaces, frameworks handle this, but hand-rolled SSR string concatenation is a classic hole. The interview-winning framing: React narrows the XSS surface to a small set of named sinks; your job is inventorying and guarding those sinks, not trusting the framework blanketly.

import DOMPurify from 'dompurify';

// Rich text (user CVs, job descriptions) rendered safely
function JobDescription({ html }) {
  const clean = DOMPurify.sanitize(html, {
    ALLOWED_TAGS: ['p', 'ul', 'ol', 'li', 'b', 'strong', 'em', 'a', 'br'],
    ALLOWED_ATTR: ['href', 'rel', 'target'],
  });
  return <div dangerouslySetInnerHTML={{ __html: clean }} />;
}

// URL protocol validation: javascript: passes JSX escaping!
function SafeLink({ href, children }) {
  let safe = '#';
  try {
    const url = new URL(href, window.location.origin);
    if (['http:', 'https:', 'mailto:'].includes(url.protocol)) {
      safe = url.href;
    }
  } catch { /* malformed: keep '#' */ }
  return (
    <a href={safe} rel='noopener noreferrer'>
      {children}
    </a>
  );
}

// NEVER: <div {...apiResponse} /> (can smuggle dangerouslySetInnerHTML)
Q58

How do micro-frontends work with React and Module Federation, and what goes wrong with multiple React copies on one page?

AdvancedArchitecture

Answer

Micro-frontends split one product across independently deployed frontend apps, usually to give large orgs (a bank's cards, loans, and payments teams; a marketplace's buyer and seller sides) independent release cadence. Module Federation, introduced in webpack 5 and now implemented runtime-agnostically (@module-federation/enhanced, with Rspack and a Vite plugin), is the dominant composition mechanism: a remote exposes modules ('./CheckoutWidget') via a manifest, a host loads them at RUNTIME, not build time, so the remote team deploys without the host rebuilding. The critical config is shared dependencies: shared: { react: { singleton: true }, 'react-dom': { singleton: true } }.

React must be a singleton per page, and interviewers probe exactly why. Two React copies mean two module instances with independent internals: hooks dispatchers, context registries, and scheduler state. A remote's component rendered inside the host's tree but built against its own React throws 'Invalid hook call' (the hook dispatcher is null because the rendering React is not the one the component imported), and even when both load, Context providers from one copy are invisible to consumers holding the other copy's context object, so themes, auth sessions, and stores silently read defaults.

Version skew adds another layer: singleton plus requiredVersion mismatches produce runtime warnings or hard failures, so federated fleets must coordinate React major upgrades, which is precisely the coupling micro-frontends promised to remove, a trade-off honest candidates name unprompted. Alternative seams: route-level splitting (each app owns URL prefixes; composition via links or a thin shell router), iframes (true isolation, painful UX integration), and Web Components wrapping each micro-app (clean events-and-attributes contract, and React 19's first-class custom-element support made this materially easier). Shared concerns needing a platform layer regardless: one design system (versioned, backwards compatible), auth token distribution, a single history/router owner to prevent navigation fights, error-boundary isolation so one team's crash does not blank the page, and per-remote chunk-load failure handling since remotes deploy independently. The senior close: micro-frontends are an organisational optimisation bought with runtime complexity; below roughly three independently shipping teams, a well-modularised monorepo with code owners delivers the same autonomy without the singleton and skew problems.

Key Points

  • Module Federation: host loads remote modules at runtime via manifest
  • react/react-dom must be shared singletons; two copies = 'Invalid hook call'
  • Context objects are per-React-copy: cross-copy providers silently miss
  • Version skew reintroduces cross-team coupling on upgrades
  • Alternatives: route-split shells, Web Components (better in React 19), monorepo modularity
Q59

Design the client state architecture for a large React product: what lives where, and how do you keep it maintainable at 50+ engineers?

AdvancedArchitecture

Answer

The senior-round question behind most others. Start by classifying state, because each class has a different correct home. Server-cache state, anything fetched, jobs, profiles, applications, chat history, goes to TanStack Query (or RTK Query / framework loaders): it is a cache with freshness policy, not app state, and duplicating it into Redux creates the two-sources-of-truth bugs that plague legacy codebases (the migration smell: reducers full of FETCH_X_SUCCESS).

URL state, filters, pagination, selected tab, search queries, anything a user should be able to share, refresh, or deep-link, belongs in the URL via the router's searchParams, not in a store; putting shareable state in memory is the most common architectural mistake in dashboard products. Form state stays local to the form via react-hook-form or useActionState. Ephemeral UI state (open modals, hover, accordion expansion) stays in useState/useReducer as close to usage as possible: colocation is the default, and every promotion of state upward must be justified by a second consumer.

What remains, genuinely global client state, is small: session/identity, feature flags, theme, a notification queue, cross-screen drafts; give it a store (Zustand slices or Redux Toolkit if the org values enforced actions and audit trails) and keep it a single-digit number of slices. Maintainability at scale is boundaries, not libraries: a feature-folder structure (features/pipeline, features/messaging) where each feature owns its components, queries, and store slice, exports a deliberate public API through an index file, and cross-feature imports are lint-enforced to go through those APIs (eslint-plugin-boundaries or Nx module boundaries); shared primitives live in a design-system package. Add conventions that survive team churn: query keys as typed factories per feature, selectors exported beside slices, mutations co-located with their invalidations, Storybook for the design system, and MSW fixtures per feature so tests do not couple to live APIs.

Guard the architecture with measurable rules, bundle-size budgets per route, dependency-cruiser checks in CI, and the review question 'which class of state is this and why does it live where you put it'. When an interviewer pushes with 'why not put everything in Redux', the answer is that each class has a lifecycle Redux does not model: server data goes stale, URL state must survive refresh, form state must reset per instance; one bucket flattens those lifecycles into hand-written imitations of them.

Key Points

  • Classify first: server-cache, URL, form, ephemeral UI, and true global state
  • Server data -> TanStack Query; shareable state -> URL; forms -> local
  • Global store is small: session, flags, theme, notifications (Zustand/RTK)
  • Feature folders with lint-enforced public APIs; design system as a package
  • Enforce with CI: bundle budgets, dependency rules, typed query-key factories
Q60

Your React app's INP is 600 ms on mid-range Android devices. Walk through how you would diagnose and fix it.

AdvancedProduction

Answer

INP (Interaction to Next Paint, the Core Web Vital that replaced FID in 2024) measures the worst-case latency from a user interaction to the next painted frame; 600 ms means taps feel broken, and 'good' is under 200 ms. Diagnose with field data first: web-vitals' onINP() (reporting the attribution build's target element and phase breakdown) piped into your analytics, or CrUX/Search Console for the population view, because lab numbers on a MacBook are fiction for an audience on Android under Rs 20,000. The attribution splits each interaction into input delay (main thread was busy when the tap landed), processing time (your handlers), and presentation delay (rendering the resulting frame); each has different fixes. Then reproduce locally: Chrome Performance panel, 6x CPU throttle, record the exact interaction, and read the long tasks.

React-specific causes in rough frequency order: (1) A state update that synchronously re-renders a huge tree, a filter tap re-rendering two thousand list rows; fix with virtualization, memoised rows, and marking the wide update as a transition so the tap's visual acknowledgement paints first. (2) Synchronous heavy computation in handlers or render, client-side search scoring, date-fns over thousands of rows; move it off the interaction (useDeferredValue), memoise it, or move it off the main thread entirely into a Web Worker via Comlink. (3) Layout thrashing: effects interleaving reads (getBoundingClientRect) and writes across many components, forcing repeated synchronous layouts; batch reads then writes, or move measurement to useLayoutEffect once. (4) Hydration collisions on load: user taps during a monolithic hydration; adopt streaming SSR with Suspense boundaries so selective hydration prioritises the tapped region. (5) Third-party scripts hogging the thread; ship them via web workers (Partytown) or delay them past first interaction. (6) Death by a thousand cuts: analytics dispatch, context cascades, and unmemoised providers each adding 20 ms; the Profiler's commit ranking finds them. Structural levers when the app is simply too heavy: route-level code splitting so less JS parses upfront, RSC to remove rendering code from the client bundle, and the React Compiler to eliminate re-render waste wholesale. Then lock it in: a Lighthouse CI or web-vitals budget in the pipeline failing PRs that regress INP, and a real-device test lane (cheap Android hardware or WebPageTest's Moto-class devices), which for Indian consumer products is the difference between dashboard metrics and reality.

import { onINP } from 'web-vitals/attribution';

// Field telemetry: know WHICH interaction is slow for real users
onINP(({ value, attribution }) => {
  navigator.sendBeacon(
    '/vitals',
    JSON.stringify({
      metric: 'INP',
      value,
      target: attribution.interactionTarget, // e.g. 'button#apply'
      inputDelay: attribution.inputDelay,
      processing: attribution.processingDuration,
      presentation: attribution.presentationDelay,
    })
  );
});

// Fix pattern: acknowledge the tap urgently, defer the heavy tree
function FilterChip({ label, onApplyFilter }) {
  const [active, setActive] = useState(false);
  const [, startTransition] = useTransition();
  return (
    <button
      aria-pressed={active}
      onClick={() => {
        setActive(a => !a); // paints immediately: cheap update
        startTransition(() => onApplyFilter(label)); // 2k-row re-render, interruptible
      }}
    >
      {label}
    </button>
  );
}

Companies Hiring React

Flipkart
Razorpay
CRED
Swiggy
Zerodha
Atlassian
Microsoft
Meesho

Salary Insights

Average in India
₹6-22 LPA

Frequently Asked Questions

How much does a React developer earn in India in 2026?

Broadly ₹6-22 LPA depending on experience and company tier. Freshers at service companies (TCS, Infosys, Wipro) start around ₹3.5-7 LPA, while product companies and GCCs offer ₹8-15 LPA for the same zero-to-two-year band. At three to six years, product companies pay ₹15-30 LPA, and top-tier employers (Flipkart, Razorpay, CRED, Atlassian, Microsoft India) go well past ₹30-45 LPA for strong senior engineers, with staff-level frontend roles crossing ₹50 LPA plus equity. The premium within React skills goes to engineers who can talk performance (Core Web Vitals on low-end Android), TypeScript fluency, testing discipline, and Next.js/RSC experience; plain component-building skills without those price at the lower end of every band.

How long should I prepare for a React interview?

With one to two years of real React work, three to four weeks of structured preparation is realistic: one week on fundamentals and hooks semantics (rendering model, effect lifecycle, closures), one on the 2026 surface (React 19 Actions, use(), Server Components, the React Compiler) plus state management and TanStack Query, and one to two weeks on machine coding, because Indian product companies almost always include a 45-90 minute build round (typeahead with debounce, kanban with drag and drop, nested comments, infinite scroll). Practise those against a timer with only the docs, no AI assistance, since most interviews still disallow it. If you are switching from Angular or Vue, add two weeks for the mental-model shift. Daily LeetCode is far less valuable here than building; JavaScript fundamentals (event loop, promises, closures, this) get tested alongside React everywhere, so keep them warm.

What do interviewers expect from freshers versus experienced React developers?

Freshers are tested on fundamentals and one honest project: explain rendering and state snapshots, use hooks without rule violations, build a small feature live (todo with filters, a paginated list), and answer JavaScript basics cleanly. Nobody expects Fiber internals; they do expect you to know why your own project's code works. At two to four years, the bar moves to hooks discipline (stale closures, effect cleanup, dependency correctness), performance debugging with the Profiler, state management trade-offs, testing with RTL, and TypeScript. At five-plus years, interviews become architecture: SSR/RSC decisions, INP budgets on real devices, state classification across a large app, design-system thinking, and mentoring signals, plus deeper dives like reconciliation, useSyncExternalStore, and streaming SSR. The consistent thread: seniors are expected to explain WHY React behaves as it does, not just which API to call.

Is React still worth learning in 2026, or should I bet on something newer?

React remains the safest frontend bet in the Indian market by a wide margin: it dominates job postings across startups, GCCs, and service companies, and the ecosystem momentum (React 19, Server Components, the React Compiler) shows active evolution rather than stagnation. Svelte and Solid are technically excellent but their Indian job market is a rounding error; Angular holds steady in enterprise and banking projects but its openings skew toward service companies; Vue sits in between. The pragmatic play is React as your primary skill with genuine depth (performance, testing, TypeScript), because 'knows React' is now table stakes while 'can make React fast on a cheap Android phone' is a differentiator. Learning a second framework broadens you but adds less market value in India than adding Next.js, React Native, or solid backend basics to a React core.

Do I need to learn Next.js along with React?

For product-company roles in 2026, effectively yes. A large share of React job descriptions list Next.js explicitly, and interviews at those companies assume you understand SSR, hydration, file-based routing, and increasingly the App Router's Server Components and Server Actions. You do not need Next.js expertise to clear a React-focused interview at every company, plenty of dashboards and internal tools are Vite SPAs, but not knowing what hydration is or when server rendering matters now reads as a gap even in SPA-only roles because those concepts leak into core React APIs (use(), Suspense, streaming). A sensible sequence: get genuinely strong at React itself first, then build one real Next.js project covering data fetching, caching, and a Server Action form. React Router v7 framework mode covers similar ground if your target companies use it.

How does React compare with React Native for career growth in India?

They share the component model and hooks, so skills transfer heavily in both directions, but they are different markets. React (web) has several times more openings across every city and company tier, making it the lower-risk primary skill. React Native demand is concentrated in consumer startups that want one team shipping iOS and Android (Swiggy, Meesho, and many fintechs run RN or have RN surfaces), and experienced RN engineers command a scarcity premium at ₹12-35 LPA because the pool is smaller: genuine RN depth means native modules, the New Architecture (Fabric, TurboModules, JSI), and app-store release engineering, not just JSX on mobile. The strong career position is React web depth plus working RN familiarity; that combination fits the many Indian teams that share code and engineers across web and app, and it lets you interview credibly for both tracks.

Introduction

React interviews in 2026 look very different from the ones five years ago. React 19 made Actions, the use() API, ref-as-a-prop, and Server Components part of the mainstream conversation, and the React Compiler (stable since late 2025) is quietly changing what interviewers expect you to say about useMemo and useCallback. Class component trivia is nearly gone; in its place, interviewers probe whether you understand rendering as a pure function of state, why effects fire when they do, and how concurrent rendering actually schedules work. If your mental model is still 'setState triggers render, virtual DOM diffs, done', a good interviewer at a product company will find the gaps within ten minutes.

In India, React remains the single most demanded frontend skill by a wide margin. Flipkart, Swiggy, Meesho, Razorpay, CRED, and Zerodha all run large React codebases, and global firms hiring from India (Atlassian, Microsoft, Uber, Google) test React knowledge even for generalist frontend roles. Expect rounds that mix conceptual questions (reconciliation, hydration, Suspense), a machine-coding exercise (typeahead, kanban board, infinite scroll built live in 45-60 minutes), and a debugging round where you explain a stale closure or a re-render storm. Senior candidates also face architecture questions: server state versus client state, code splitting strategy, and how you would keep INP under 200 ms on a mid-range Android phone, which is where most Indian traffic lives.

This guide contains 60 React interview questions arranged from basic through advanced, each answered the way a strong candidate would answer in the room: concrete APIs, real failure modes, and the trade-offs interviewers actually push on. The basic section locks down fundamentals that filter out most candidates. The intermediate section covers hooks discipline, state management in 2026, testing, and TypeScript. The advanced section goes where senior offers are decided: Fiber, Server Components, the React Compiler, streaming SSR, useSyncExternalStore, and production performance work. Work through them in order, and type out every code example rather than skimming it.

Ready to practice React interviews?

Don't just read, practice these React questions live with an AI interviewer that asks follow-ups and scores your answers.

AI-powered practice
Instant feedback
Free to start
Start Free Mock Interview