React Interview Questions and Answers
Last updated:
Check out 96 of the most common React interview questions, then take an AI-powered practice interview
Q1What is React, and why is it used?
BasicBasics
Answer
React is an open-source JavaScript library for building user interfaces, created at Facebook (now Meta) and released in 2013. You describe the UI as a tree of components, each a function that turns props and state into markup, and React keeps the browser DOM in sync when that state changes. It is a library, not a full framework: routing, data fetching and server rendering come from the ecosystem (React Router, TanStack Query, Next.js).
It is used because the component model scales. A button, a form, a whole page are all components with the same contract, so teams of hundreds of engineers can work in one codebase; the declarative style (say what the screen should look like for this state, not how to mutate the DOM) removes a whole class of bugs; and the same model runs on the web, on mobile through React Native, and on the server through Server Components.
In India it is the frontend skill most job descriptions ask for, across service companies, product companies and startups, which is why almost every frontend round opens with React questions. The current stable line is React 19.3 (September 2026).
import { createRoot } from 'react-dom/client';
// A component: a function from props to UI
function Greeting({ name }) {
return <h1>Hello, {name}</h1>;
}
// Mount it once; React updates the DOM from here on
createRoot(document.getElementById('root')).render(<Greeting name="Priya" />);Key Points
- Library for building UIs out of components; not a full framework
- Declarative: describe the UI for a given state, React updates the DOM
- One mental model for web, React Native and Server Components
- Created at Meta; since February 2026 owned by the independent React Foundation (Linux Foundation)
- Current stable line: React 19.3 (September 2026)
Q2What are the main features of React?
BasicBasics
Answer
The features interviewers expect you to list, with one line of why each matters: components (reusable units with their own props and state), JSX (markup inside JavaScript that compiles to function calls), the Virtual DOM and reconciliation (React diffs element trees and applies minimal DOM updates), one-way data flow (data goes down through props, intent goes up through callbacks, so changes are traceable), and hooks (useState, useEffect and friends give function components state and side effects without classes).
Since React 18 and 19 the list has grown: concurrent rendering (React can pause, interrupt and resume rendering work, which powers useTransition and Suspense), Server Components and Actions (components that run only on the server, and async functions wired to forms), and the React Compiler (build-time automatic memoisation, stable since October 2025).
A strong answer groups these into 'the core model' (components, JSX, one-way data flow), 'how it updates the screen' (Virtual DOM, reconciliation, concurrent rendering) and 'how it grew up' (hooks, Server Components, the compiler).
Key Points
- Component-based architecture with reusable, composable units
- JSX: HTML-like syntax compiled to element-creating function calls
- Virtual DOM + reconciliation: minimal real DOM updates
- Unidirectional data flow: props down, callbacks up
- Hooks for state and effects; concurrent rendering; Server Components; React Compiler
Q3What is JSX, what does it compile to, and why do you no longer need to import React in every file?
BasicBasics
Answer
JSX stands for JavaScript XML. It is a syntax extension that lets you write HTML-like markup inside JavaScript, and browsers cannot run it directly: a build tool compiles each tag into a function call that returns a plain element object. That is why you write className instead of class and wrap expressions in curly braces.
JSX is syntax sugar that a compiler (Oxc inside Vite 8, 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.transitional.element') marker in React 19 (Symbol.for('react.element') before it), 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(), is a bug: React never sees it as a component, so its hooks run inside the caller's hook list, which breaks as soon as the call is conditional or in a loop, and it gets no identity, key or state of its own. In 2026 you should also mention that React 19 requires the automatic JSX transform for its new features and warns on the classic one (createElement itself still works) 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'
Q4What are the rules of JSX?
BasicBasics
Answer
JSX compiles to function calls, so its rules are the rules of a function that returns one value. Return a single parent element (wrap siblings in a Fragment <>...</> if you do not want an extra DOM node). Close every tag, including <img />, <input /> and <br />. Use camelCase attribute names and the DOM property names: className instead of class, htmlFor instead of for, onClick instead of onclick, tabIndex instead of tabindex.
Put JavaScript expressions in curly braces: {user.name}, {items.length > 0 && <List />}, {format(date)}. Statements (if, for) are not allowed inside braces, so use ternaries, && or compute the value before the return. The style attribute takes an object with camelCase keys, not a string: style={{ marginTop: 8 }}. Every item produced by map() needs a key. Comments are written as {/* comment */}.
Three details that separate candidates: JSX escapes strings by default, so {userInput} cannot inject HTML; boolean attributes like disabled={true} can be written as just disabled; and a component name must start with a capital letter, because <button> means the DOM tag and <Button> means your component.
function Profile({ user, skills }) {
// One parent (a Fragment here), className not class, every tag closed,
// camelCase attributes, expressions in braces, key on list items
return (
<>
<img src={user.avatar} alt={user.name} className="avatar" />
<label htmlFor="bio">Bio</label>
<p id="bio" style={{ color: 'gray' }}>
{user.bio ?? 'No bio yet'}
</p>
<ul>
{skills.map(skill => (
<li key={skill}>{skill}</li>
))}
</ul>
{/* This is a JSX comment */}
</>
);
}Key Points
- One root element per return; use a Fragment to avoid wrapper divs
- Close every tag; camelCase attributes; className and htmlFor
- Expressions in braces, statements outside the JSX
- style takes an object; map() output needs keys
- Capitalised names are components, lowercase names are DOM tags
Q5What is state in React, and what is the difference between state and props?
BasicProps and State
Answer
State is data a component owns and can change over time, declared with useState (or useReducer). When state changes, React re-renders that component and its children. Props are inputs passed in by the parent and are read-only inside the child. In one line: props are what a component is given, state is what it remembers.
The comparison interviewers want, aspect by aspect. Mutability: props cannot be changed by the receiver; state is changed through its setter. Ownership: props belong to the parent; state belongs to the component that declares it. Purpose: props configure a child; state tracks what changes as the user interacts. Re-renders: a new prop value from the parent re-renders the child; a state update re-renders the owner. Access: a child reads its props; a sibling or parent cannot read another component's state unless it is lifted up or shared through Context.
Two follow-ups to be ready for: copying a prop into state is almost always a bug (the state goes stale when the prop changes; derive it instead), and state updates are asynchronous snapshots, so reading state right after setting it gives the old value.
import { useState } from 'react';
function Parent() {
const [count, setCount] = useState(0); // state: owned and changed here
return (
<Counter value={count} onIncrement={() => setCount(c => c + 1)} />
);
}
function Counter({ value, onIncrement }) {
// props: read-only inputs; the child asks the parent to change them
return <button onClick={onIncrement}>Clicked {value} times</button>;
}Key Points
- Props: immutable, passed from parent, configure the child
- State: mutable via setter, owned by the component, tracks change
- Both trigger a re-render when they change
- Never copy a prop into state; derive values during render instead
Q6What are props, why are they read-only, and what does one-way data flow mean?
BasicProps and State
Answer
Props (short for properties) are the read-only inputs a parent passes to a child component, like arguments to a function: <Greeting name='Priya' /> gives Greeting a props object of { name: 'Priya' }. A child never modifies its props; it renders from them and asks the parent to change them through a callback.
React treats props 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
Q7Why must state updates be immutable in React, and what does the classic mutation bug look like in a code review?
BasicProps and State
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';
})
);
Q8What is the Virtual DOM, and how does it work?
BasicRendering
Answer
The Virtual DOM is a lightweight, in-memory tree of plain JavaScript objects (React elements) that describes what the real DOM should look like. When state changes, React calls your components to build a new tree, compares it with the previous one (diffing), and applies only the differences to the real DOM (reconciliation and commit). Touching the real DOM is slow because it triggers style and layout work; comparing JavaScript objects is cheap.
How it works, step by step: a setter call schedules an update; React re-runs the affected component functions to produce new element trees; it walks the new tree against the Fiber tree it kept from the last render, matching nodes by type and key; it records the minimal set of changes (insert, update attribute, remove); and in the commit phase it applies them to the DOM in one batch, then runs layout effects and passive effects.
The nuance that impresses interviewers: the Virtual DOM is not faster than careful manual DOM code. Its value is that it lets you write declarative components and still get good enough DOM performance by default, and since React 16 the Fiber implementation lets that diffing work be split, paused and prioritised, which is what concurrent features build on.
Key Points
- In-memory tree of element objects describing the intended UI
- Render phase builds a new tree; diffing finds what changed
- Commit phase applies minimal DOM mutations in one batch
- Matches nodes by type and key; different type means replace the subtree
- Value is declarative code with predictable performance, not raw speed
Q9When state changes in React, what actually happens between the setState call and the screen updating?
BasicRendering
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 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
Q10Why do we need the key prop in lists, and why does using the array index as a key cause bugs?
BasicRendering
Answer
Keys give each list item a stable identity so React can tell which items were added, removed or reordered between renders, and keep the right component state and DOM node attached to the right item. Use a unique id from your data. The array index only works for static lists that never reorder, insert or delete.
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 a common real-world React bug.
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} />
Q11Why does {count && <Badge />} render a literal 0 on screen, and what are the safe conditional rendering patterns?
BasicRendering
Answer
JSX renders false, null, undefined and true as nothing, but the falsy numbers 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 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>;
Q12Why 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
Q13What 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.
Q14What is the difference between client-side rendering and server-side rendering in React?
BasicRendering
Answer
In client-side rendering (CSR) the server sends an almost empty HTML file plus a JavaScript bundle; the browser downloads and runs the bundle, React renders the page in the browser, and only then does the user see content. In server-side rendering (SSR) the server runs React, sends fully formed HTML for the requested URL, the browser shows it immediately, and React then 'hydrates' it by attaching event handlers so it becomes interactive.
SSR gives a faster first paint on slow networks and devices (common on Indian mobile networks), lets crawlers and link previews see real content, and is why marketing pages, job listings and e-commerce use Next.js or React Router framework mode. CSR is simpler to host (any static file server), needs no Node server, and is fine for dashboards and tools behind a login where SEO does not matter. The hidden cost of SSR is hydration: the JavaScript still has to download and run before clicks work, and any mismatch between server and client output throws hydration errors.
The modern spectrum also includes static site generation (render at build time), incremental static regeneration (re-render in the background on a schedule), streaming SSR (send HTML in chunks as Suspense boundaries resolve) and Server Components (components that never ship JavaScript to the client at all).
Key Points
- CSR: empty HTML + bundle, browser renders; simple hosting, slower first paint
- SSR: server sends HTML, client hydrates; better SEO and first paint
- Hydration is SSR's cost: JS must still load before interaction
- SSG, ISR, streaming SSR and Server Components extend the spectrum
Q15What is a Fragment, and why use it?
BasicComponents
Answer
A Fragment groups several children without adding an extra element to the DOM. A component must return one node, and wrapping everything in a <div> breaks layouts that depend on parent-child structure: table rows inside <tbody>, items inside a flex or grid container, <dt>/<dd> pairs inside <dl>. <Fragment>...</Fragment> or the shorthand <>...</> returns the children as siblings instead.
The shorthand cannot take attributes, so when a Fragment is produced inside map() and needs a key you must write <Fragment key={item.id}> explicitly. React 19.3 added Fragment refs: passing a ref to <Fragment> gives you a FragmentInstance with methods like focus(), addEventListener() and getClientRects() that act on the group of DOM children, so you can observe or focus a set of siblings without introducing a wrapper element.
import { Fragment } from 'react';
function Glossary({ items }) {
return (
<dl>
{items.map(item => (
// The <> shorthand cannot take a key, so use Fragment here
<Fragment key={item.id}>
<dt>{item.term}</dt>
<dd>{item.description}</dd>
</Fragment>
))}
</dl>
);
}
function Row({ cells }) {
// Valid: <td> siblings straight into <tr>, no stray <div> inside a table
return (
<>
{cells.map(cell => (
<td key={cell.id}>{cell.text}</td>
))}
</>
);
}Key Points
- Returns multiple siblings without a wrapper DOM node
- Shorthand <> </> has no attributes; use <Fragment key=...> in lists
- Keeps valid HTML structure in tables, lists and grid containers
- React 19.3: ref on Fragment exposes a FragmentInstance for the group
Q16What is the difference between an element and a component?
BasicComponents
Answer
An element is a plain JavaScript object that describes what you want on screen: a type (a tag name string like 'h1', or a component function), props, and children. <h1>Hi</h1> is an element. Elements are cheap, immutable and created on every render; they are what the Virtual DOM is made of. A component is a function (or, in legacy code, a class) that accepts props and returns elements. Elements describe, components produce.
The distinction matters in three places. When you write <Badge label='New' />, you are not calling Badge; you create an element whose type is the Badge function, and React decides when to call it. Comparing element.type is how reconciliation decides whether to keep or replace a subtree. And a component instance (its state, refs and position in the Fiber tree) is something React manages for you; you never hold an instance of a function component yourself.
// A component: a function that returns elements
function Badge({ label }) {
return <span className="badge">{label}</span>;
}
// An element: a plain object describing what to render
const el = <Badge label="New" />;
console.log(typeof el); // 'object'
console.log(el.type === Badge); // true: the type is the component function
console.log(el.props); // { label: 'New' }
// For a DOM tag the type is a string
console.log((<h1>Hi</h1>).type); // 'h1'Key Points
- Element: immutable object { type, props, children } describing UI
- Component: function from props to elements; React calls it
- JSX creates elements, it never calls your component directly
- Reconciliation compares element types to decide reuse versus replace
Q17What is the difference between functional and class components, and which should you use in 2026?
BasicComponents
Answer
A functional component is a plain function that takes props and returns JSX; it gets state from useState and side effects from useEffect. A class component extends React.Component, holds state in this.state, updates it with this.setState, and spreads side effects across lifecycle methods (componentDidMount, componentDidUpdate, componentWillUnmount). Functional components need no this binding, produce less code, let you reuse stateful logic through custom hooks, and are the only kind the React Compiler optimises and the only kind that can be Server Components.
Use functional components for all new code. The React team has not deprecated classes, and you will still read them in codebases started before 2019, so an interviewer may ask you to explain or convert one. The one job that still needs a class is an error boundary (getDerivedStateFromError and componentDidCatch have no hook equivalent), which is why teams wrap that single class in a library like react-error-boundary.
A sharp comparison to say out loud: classes split one concern (a subscription) across three lifecycle methods, while a hook keeps setup and cleanup together in one effect.
import { Component, useEffect, useState } from 'react';
// Legacy class: one concern split across three methods
class ClockClass extends Component {
state = { now: new Date() };
componentDidMount() {
this.timer = setInterval(() => this.setState({ now: new Date() }), 1000);
}
componentWillUnmount() {
clearInterval(this.timer);
}
render() {
return <p>{this.state.now.toLocaleTimeString()}</p>;
}
}
// Modern function component: setup and cleanup live together
function ClockFn() {
const [now, setNow] = useState(() => new Date());
useEffect(() => {
const timer = setInterval(() => setNow(new Date()), 1000);
return () => clearInterval(timer);
}, []);
return <p>{now.toLocaleTimeString()}</p>;
}Key Points
- Function + hooks: less code, no this, logic reuse via custom hooks
- Class: this.state, setState, lifecycle methods; legacy but still read
- Only function components work with the React Compiler and Server Components
- Error boundaries are the one remaining class-only feature
Q18What is the React component lifecycle, and how do lifecycle methods map to hooks?
BasicComponents
Answer
A component goes through three phases: mounting (created and inserted into the DOM), updating (re-rendered because props or state changed) and unmounting (removed from the DOM). Class components expose these as methods: constructor and componentDidMount for mounting, componentDidUpdate for updates, componentWillUnmount for cleanup, plus shouldComponentUpdate to skip renders and componentDidCatch for errors.
Function components express the same phases with hooks. useEffect with an empty dependency array runs once after mount; with dependencies it runs after mount and after any update where those values changed; the function it returns runs before the next effect and on unmount, covering componentWillUnmount. useLayoutEffect is the same but fires before the browser paints. shouldComponentUpdate becomes React.memo on the component. There is no hook for componentDidCatch; error boundaries stay classes.
The mental-model shift interviewers listen for: hooks are not lifecycle methods in disguise. An effect declares 'keep this external thing in sync with these values', and React decides when to run it, which is why one effect can replace three lifecycle methods and why it may run more often than you expect in StrictMode.
import { useEffect, useState } from 'react';
function UserCard({ userId }) {
const [user, setUser] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
// Runs after mount, and again whenever userId changes
// (componentDidMount + the userId part of componentDidUpdate)
const controller = new AbortController();
fetch(`/api/users/${userId}`, { signal: controller.signal })
.then(res => res.json())
.then(setUser)
.catch(err => {
if (err.name !== 'AbortError') setError(err);
});
return () => {
// Runs before the next run and on unmount (componentWillUnmount)
controller.abort();
};
}, [userId]);
if (error) return <p>Could not load user.</p>;
if (!user) return <p>Loading...</p>;
return <h2>{user.name}</h2>;
}Key Points
- Mounting, updating, unmounting; classes use one method per phase
- useEffect(fn, []) ~ componentDidMount; deps ~ componentDidUpdate
- Effect cleanup ~ componentWillUnmount, and runs before every re-run
- React.memo ~ shouldComponentUpdate; error boundaries remain classes
Q19How do you build reusable layout components with children and slot props instead of configuration props?
BasicComponents
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 large product companies run 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>;
Q20What are React Hooks, and what are the rules of hooks?
BasicHooks
Answer
Hooks are functions, all named use-something, that let function components use React features: useState for state, useEffect for side effects, useContext for shared values, useRef for mutable references, useReducer, useMemo, useCallback, and newer ones like useTransition, useId, useActionState and useOptimistic. They were introduced in React 16.8 (February 2019) so that state and lifecycle logic no longer required class components.
There are two rules. Only call hooks at the top level of a component or custom hook: never inside loops, conditions, nested functions or after an early return, because React stores hook state in a list and identifies each hook by its call order, so the order must be identical on every render. Only call hooks from React function components or from custom hooks, never from ordinary JavaScript functions or class components. eslint-plugin-react-hooks enforces both, and in its current major version its React Compiler-powered rules also catch mutation during render and ref reads during render.
The one exception interviewers like to probe: React 19's use() API reads a promise or a Context and may be called conditionally or inside loops, because it is not a hook in the call-order sense.
import { useEffect, useState } from 'react';
function Profile({ userId }) {
const [user, setUser] = useState(null); // OK: top level, same order every render
useEffect(() => {
fetch(`/api/users/${userId}`).then(r => r.json()).then(setUser);
}, [userId]); // OK: top level
if (!userId) {
// WRONG: a hook inside a condition changes the call order between renders
// const [draft, setDraft] = useState('');
return <p>Select a user</p>;
}
return <p>{user ? user.name : 'Loading...'}</p>;
}Key Points
- Hooks give function components state, effects, context and refs
- Rule 1: call at the top level, never in conditions, loops or after a return
- Rule 2: call only from components or custom hooks
- React relies on stable call order; the lint plugin enforces the rules
- use() in React 19 may be called conditionally; regular hooks may not
Q21How does useState work, and why does calling setCount(count + 1) three times increment by one?
BasicHooks
Answer
useState adds local state to a function component. const [count, setCount] = useState(0) returns the current value and a setter; calling the setter schedules a re-render with the new value. The value you read during a render is a snapshot of that render, which is exactly why the triple call below only adds one.
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>;
}
Q22What is useEffect used for, and what does each form of the dependency array do?
BasicHooks
Answer
useEffect runs side effects after React has committed a render: fetching data, subscribing to a socket, starting a timer, or touching the DOM by hand. Its second argument, the dependency array, decides when it re-runs, and the function it returns is the cleanup. It is for synchronising with things outside React, not for computing values.
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 now: 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
Q23What is useRef used for, and how is it different from useState?
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>
</>
);
}
Q24What is a custom hook? Write one.
BasicHooks
Answer
A custom hook is a function whose name starts with use and that calls other hooks. It packages stateful logic (a subscription, a debounce, a fetch, a local storage sync) so several components can reuse it without duplicating code or wrapping themselves in extra components. Each component that calls the hook gets its own isolated state; hooks share logic, not state.
The example below, useDebouncedValue, is a favourite in Indian product-company rounds because it leads straight into a typeahead task. It keeps a second piece of state that only catches up with the input after the user pauses for delay milliseconds, and clears the pending timer whenever value changes or the component unmounts.
When you write a custom hook in an interview, say three things: the name starts with use so the lint rules apply, every hook call inside it is still at the top level, and the return shape (a value, a tuple, or an object) is a design choice you should justify.
import { useEffect, useState } from 'react';
export function useDebouncedValue(value, delay = 300) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(id); // cancel if value changes before delay
}, [value, delay]);
return debounced;
}
// Usage: fires the search only after the user pauses typing
function Search() {
const [query, setQuery] = useState('');
const debouncedQuery = useDebouncedValue(query, 400);
useEffect(() => {
if (debouncedQuery) console.log('search for', debouncedQuery);
}, [debouncedQuery]);
return <input value={query} onChange={e => setQuery(e.target.value)} />;
}Key Points
- A function named useX that calls other hooks
- Shares logic; every caller gets independent state
- Must follow the rules of hooks inside, and be called at the top level
- Common asks: useDebouncedValue, useLocalStorage, useFetch, useWindowSize
Q25What is a higher-order component, and how do HOCs, render props and custom hooks compare for sharing logic?
BasicHooks
Answer
A higher-order component (HOC) is a function that takes a component and returns a new component with extra props or behaviour, for example withAuth(Dashboard) that redirects unauthenticated users. Render props pass a function as a prop that returns UI. Custom hooks, which share stateful logic without wrapping components, replaced both in modern code.
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.
HOCs such as withRouter and Redux's old connect bring wrapper hell in DevTools, prop-name collisions when stacking, 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.
import { useEffect, useState } from 'react';
import { Navigate } from 'react-router';
// 1. Higher-order component: takes a component, returns a wrapped one
function withAuth(Wrapped) {
return function WithAuth(props) {
const user = useCurrentUser(); // your auth hook
if (!user) return <Navigate to="/login" replace />;
return <Wrapped {...props} user={user} />;
};
}
const Dashboard = withAuth(DashboardView);
// 2. Render prop: the caller decides what to render with the shared data
function MouseTracker({ render }) {
const [pos, setPos] = useState({ x: 0, y: 0 });
return (
<div onMouseMove={e => setPos({ x: e.clientX, y: e.clientY })}>
{render(pos)}
</div>
);
}
// <MouseTracker render={pos => <p>{pos.x}, {pos.y}</p>} />
// 3. Custom hook: same logic, no wrapper component, isolated state per caller
function useMousePosition() {
const [pos, setPos] = useState({ x: 0, y: 0 });
useEffect(() => {
const onMove = e => setPos({ x: e.clientX, y: e.clientY });
window.addEventListener('mousemove', onMove);
return () => window.removeEventListener('mousemove', onMove);
}, []);
return pos;
}
// function Coords() { const { x, y } = useMousePosition(); return <p>{x}, {y}</p>; }Q26What is the difference between React.memo, useMemo and useCallback?
BasicPerformance
Answer
All three are memoisation tools, but they cache different things. React.memo wraps a component: when the parent re-renders, React shallowly compares the new props with the previous ones and skips re-rendering the component if nothing changed. useMemo caches a computed value inside a component and recomputes it only when its dependencies change. useCallback caches a function reference so that the same function object is passed on every render; useCallback(fn, deps) is literally useMemo(() => fn, deps).
They are used together: memo on the child only helps if the props it receives are referentially stable, which is what useMemo (for object and array props) and useCallback (for handler props) provide. Memoising a value that is cheap to compute, or a callback passed to a plain DOM element, buys nothing and costs a comparison.
Since the React Compiler went stable in October 2025, new codebases that enable it get this memoisation automatically; interviewers now expect you to explain the referential-equality model behind these APIs rather than to sprinkle them everywhere.
import { memo, useCallback, useMemo } from 'react';
const Row = memo(function Row({ item, onSelect }) {
return <li onClick={() => onSelect(item.id)}>{item.name}</li>;
});
function List({ items, sortKey }) {
// Cached value: recomputed only when items or sortKey change
const sorted = useMemo(
() => [...items].sort((a, b) => a[sortKey].localeCompare(b[sortKey])),
[items, sortKey],
);
// Cached function: same reference every render, so Row's memo holds
const onSelect = useCallback(id => console.log('selected', id), []);
return (
<ul>
{sorted.map(item => (
<Row key={item.id} item={item} onSelect={onSelect} />
))}
</ul>
);
}Key Points
- React.memo: skip a component's re-render when props are shallow-equal
- useMemo: cache a computed value; useCallback: cache a function reference
- memo only works if the props passed in are referentially stable
- The React Compiler automates all three in new code
Q27How does event handling work in React, and what is a SyntheticEvent?
BasicEvents and Forms
Answer
React events use camelCase names (onClick, onChange) and take a function, not a string: <button onClick={handleClick}>. The handler receives a SyntheticEvent, React's cross-browser wrapper around the native event with the same interface (target, preventDefault, stopPropagation).
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 later React handlers and native listeners above the root (document, window), but native listeners on DOM ancestors inside the root have already fired, because React only sees the event at the root; 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
Q28What are controlled and uncontrolled components, and when is each the right choice?
BasicEvents and Forms
Answer
In a controlled component, React state is the single source of truth for a form field: value comes from state and onChange writes back to it. In an uncontrolled component the DOM holds the value and you read it with a ref or FormData when you need it. Controlled inputs give instant validation; uncontrolled inputs mean fewer re-renders.
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>
);
}
Q29How do you pass an argument to an event handler in React?
BasicEvents and Forms
Answer
Wrap the call in an arrow function: onClick={() => onApply(job.id)}. React calls your handler with the event when the click happens; the arrow function receives it and calls onApply with the argument you want. If you also need the event, take it as a parameter: onClick={e => onApply(job.id, e)}.
The classic mistake is onClick={onApply(job.id)}, which calls onApply during render and passes its return value (usually undefined) as the handler, so nothing happens on click and in a loop you may fire the function once per item on every render. bind works too (onClick={onApply.bind(null, job.id)}) but reads as legacy.
If you are worried about creating a new function per item per render, measure first; it rarely matters. When it does (a memoised child in a very long list), read the argument from a data attribute on the element or let the React Compiler memoise the callbacks for you.
function JobList({ jobs, onApply }) {
return jobs.map(job => (
<button key={job.id} onClick={() => onApply(job.id)}>
Apply to {job.title}
</button>
));
}
// WRONG: calls onApply during render, passes undefined as the handler
// <button onClick={onApply(job.id)}>Apply</button>
// With the event too
// <button onClick={e => onApply(job.id, e)}>Apply</button>
// Without a per-item closure: read the id from a data attribute (it is a string)
function JobListData({ jobs, onApply }) {
function handleClick(e) {
onApply(e.currentTarget.dataset.id);
}
return jobs.map(job => (
<button key={job.id} data-id={job.id} onClick={handleClick}>
Apply
</button>
));
}Key Points
- onClick={() => fn(arg)} passes the argument when the event fires
- onClick={fn(arg)} runs during render and breaks the handler
- Take the event as a parameter when you need preventDefault or target
- Data attributes avoid per-item closures; values arrive as strings
Q30How do React 19 form Actions work with useActionState and useFormStatus, and what do they replace?
BasicEvents and Forms
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>
);
}
Q31When should you use the Context API versus Redux?
BasicState Management
Answer
Use Context for values that change rarely and are read widely: the current theme, the logged-in user, the locale, a feature-flag set. Use Redux (today, Redux Toolkit) when many unrelated parts of the app read and write the same frequently changing state and you need predictable updates, middleware, DevTools and selectors.
Context is built in and needs no dependency, but every component reading it re-renders when the Provider value changes, so it performs badly for data that updates several times a second. Redux's subscription model with selectors re-renders only the components whose slice changed, adds middleware for async flows and logging, and gives time-travel debugging through DevTools.
The decision in practice: small and medium apps with server data in TanStack Query or RTK Query rarely need Redux at all, because most 'global state' turns out to be cached server data. What remains (auth, theme, a cart) fits Context or a tiny store like Zustand. Large products with complex client-side workflows (an editor, a trading screen, a multi-step recruiter pipeline) justify Redux Toolkit.
Say the trade-off out loud: Context is dependency injection, not a state manager; Redux is a state manager with a subscription model that avoids the re-render cost Context has.
Key Points
- Context: low-frequency, widely-read values; no library; re-renders every consumer
- Redux Toolkit: high-frequency shared state, middleware, DevTools, selectors
- Most 'global state' is cached server data: TanStack Query or RTK Query, not Context or Redux
- Zustand or Jotai sit between the two for small global client state
Q32What is prop drilling, when should you lift state up, and when is Context the right tool?
BasicState Management
Answer
Prop drilling is passing a prop through several components that do not use it, only so a deeply nested child can read it. Two or three levels are fine; five levels across unrelated components is a smell. The fixes, in order of preference: composition (pass children), lifting state only as high as needed, then Context for truly global values.
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. 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
Q33What is the Context API, and which components re-render when a Provider's value changes?
BasicState Management
Answer
The Context API shares a value (theme, logged-in user, locale) with every component below a Provider without passing props at each level. You create it with createContext, supply it with a Provider, and read it with useContext or use. The cost: every component that reads the context re-renders when the Provider's value changes.
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);
Q34What is Redux, what are its three core principles, and what is Redux Toolkit?
BasicState Management
Answer
Redux is a predictable state container for JavaScript apps. All application state lives in one store; the only way to change it is to dispatch an action, a plain object with a type and optional payload describing what happened; and reducers, pure functions of (state, action) that return the next state, decide how the state changes. Those are its three principles: single source of truth, state is read-only, changes are made with pure functions. Components subscribe to the store with useSelector and dispatch with useDispatch from react-redux.
Redux Toolkit (RTK) is the official way to write Redux since 2019. configureStore sets up the store with DevTools and thunk middleware already wired; createSlice generates the reducer and action creators from one object and uses Immer so you can write state.items.push(x) in a reducer while the real state stays immutable; createAsyncThunk and RTK Query handle async data. Plain Redux with hand-written action constants and switch statements is what you will see in older Indian service-company codebases, so know both.
Middleware sits between dispatch and the reducer: logging, crash reporting and async logic (thunk, saga, RTK Query) all live there.
import { configureStore, createSlice } from '@reduxjs/toolkit';
import { Provider, useDispatch, useSelector } from 'react-redux';
const cartSlice = createSlice({
name: 'cart',
initialState: { items: [] },
reducers: {
// Immer lets you write mutations; the real state stays immutable
addItem(state, action) {
state.items.push(action.payload);
},
removeItem(state, action) {
state.items = state.items.filter(item => item.id !== action.payload);
},
},
});
export const { addItem, removeItem } = cartSlice.actions;
export const store = configureStore({ reducer: { cart: cartSlice.reducer } });
function CartButton() {
const count = useSelector(state => state.cart.items.length);
const dispatch = useDispatch();
return (
<button onClick={() => dispatch(addItem({ id: Date.now(), name: 'Mock interview pack' }))}>
Cart ({count})
</button>
);
}
export default function App() {
return (
<Provider store={store}>
<CartButton />
</Provider>
);
}Key Points
- Store, actions, reducers; dispatch is the only way to change state
- Principles: single source of truth, read-only state, pure reducers
- RTK: configureStore + createSlice with Immer; far less boilerplate
- Middleware (thunk, saga, RTK Query) handles async and logging
Q35What is React Router, and what are its main components?
BasicRouting
Answer
React Router is the standard client-side routing library for React single-page apps. It maps URL paths to components, swaps the rendered component when the URL changes without a full page reload, and keeps the browser history in sync so back and forward work. The current major version is React Router v8 (June 2026), published only as the react-router package: v8 removed the react-router-dom re-export, and DOM pieces such as RouterProvider import from react-router/dom. Framework mode adds server rendering and type-safe routes.
The pieces you must be able to name: a router (createBrowserRouter plus RouterProvider in modern code, or the <BrowserRouter> wrapper in older code) that listens to the URL; route definitions that pair a path with an element, with nested children rendered through <Outlet />; <Link> and <NavLink> for navigation without reloads (NavLink adds an active class); dynamic segments like /jobs/:id read with useParams; useNavigate for programmatic navigation; and a catch-all path '*' for the 404 page.
Interviewers often follow up with 'why not plain anchor tags': an <a href> reloads the page and loses all client state, while <Link> uses the History API and only re-renders the matched routes.
import { createBrowserRouter, Link, Outlet } from 'react-router';
import { RouterProvider } from 'react-router/dom';
function Layout() {
return (
<>
<nav>
<Link to="/">Home</Link> <Link to="/jobs">Jobs</Link>
</nav>
<Outlet /> {/* the matched child route renders here */}
</>
);
}
const router = createBrowserRouter([
{
path: '/',
element: <Layout />,
children: [
{ index: true, element: <Home /> },
{ path: 'jobs', element: <Jobs /> },
{ path: 'jobs/:id', element: <JobDetail /> },
{ path: '*', element: <NotFound /> },
],
},
]);
export default function App() {
return <RouterProvider router={router} />;
}
// Older JSX style, still valid:
// <BrowserRouter><Routes><Route path="/" element={<Home />} /></Routes></BrowserRouter>Key Points
- Maps URLs to components without full page reloads
- createBrowserRouter + RouterProvider, or BrowserRouter + Routes + Route
- Link / NavLink for navigation, Outlet for nested routes
- useParams for :id segments, useNavigate for redirects, '*' for 404
Q36How do you read route parameters, navigate programmatically, and protect a route in React Router?
BasicRouting
Answer
useParams() returns an object of the dynamic segments in the matched path, so for /jobs/:id it gives { id: '42' }; the values are always strings, so convert before comparing with numbers. useSearchParams() handles the query string (?page=2). useNavigate() returns a function: navigate('/dashboard') pushes a new entry, navigate('/login', { replace: true }) replaces the current one (right after a successful login so Back does not return to the form), and navigate(-1) behaves like the browser Back button.
A protected route is a component that checks auth and either renders <Outlet /> (letting the child routes through) or <Navigate to='/login' />. Pass the attempted location in state so the login page can send the user back where they were going. The same pattern handles role-based access by checking user.role.
In React Router framework mode and in Next.js, this check increasingly moves to the server (a loader or proxy that redirects before any HTML is sent), which is the better answer for pages that must not even flash protected content.
import { useParams, useNavigate, Navigate, useLocation, Outlet } from 'react-router';
function JobDetail() {
const { id } = useParams(); // always a string: '42'
const navigate = useNavigate();
return (
<>
<h1>Job #{id}</h1>
<button onClick={() => navigate('/jobs')}>All jobs</button>
<button onClick={() => navigate(-1)}>Back</button>
</>
);
}
// Protected routes: wrap children; redirect when not logged in
function RequireAuth() {
const { user } = useAuth(); // your auth hook
const location = useLocation();
if (!user) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
return <Outlet />;
}
// Route config:
// { element: <RequireAuth />, children: [{ path: 'dashboard', element: <Dashboard /> }] }
// After login: send the user back where they were going
// const from = location.state?.from?.pathname ?? '/';
// navigate(from, { replace: true });Key Points
- useParams gives string values for :segments; useSearchParams for the query
- navigate(path), navigate(path, { replace: true }), navigate(-1)
- Protected route = check auth, render Outlet or Navigate to login
- Carry the attempted location in state to return after login
Q37What is Next.js, and why do companies use it with React?
BasicNext.js
Answer
Next.js is a React framework from Vercel that adds what the React library leaves out: file-based routing (a folder under app/ becomes a URL), server-side rendering, static generation and incremental regeneration, React Server Components and Server Actions, API endpoints through route handlers, image and font optimisation, and a production build pipeline. It is the framework the React docs recommend first for new full-stack apps, and it now appears in many React job descriptions at Indian product companies.
The current major is Next.js 16 (October 2025, with 16.4 in October 2026): Turbopack is the default bundler for dev and build, middleware was renamed proxy.ts, the React Compiler is supported with one config flag, and Cache Components with the 'use cache' directive make caching explicit and opt-in instead of implicit. Next.js 16.4 (October 2026) ships React 19.3 features.
Why companies pick it: SEO-dependent pages get server-rendered HTML without a custom Node setup; Server Components cut the JavaScript sent to low-end phones; one repo holds UI and backend-for-frontend code; and deployment is standardised. The honest counterpoint interviewers like: a Vite SPA is still the simpler choice for internal tools behind a login.
Key Points
- React framework: routing, SSR/SSG/ISR, Server Components, route handlers
- Next.js 16: Turbopack default, proxy.ts, React Compiler flag, Cache Components
- Chosen for SEO, smaller client bundles, full-stack in one repo
- Vite + React Router remains fine for dashboards behind a login
Q38What 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 since React 18 nothing is suppressed, but React DevTools dims the second render's logs (and can hide them), which is why people see one bright 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
Q39Create 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 near-instant dev startup and fast production builds (since Vite 8 in March 2026 one Rust bundler, Rolldown, handles both, with Oxc compiling TypeScript and JSX), 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 v8 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}
Q40How 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 drop the legacy isMounted flag; aborting in cleanup already prevents setState after unmount. Then name the trade-off: 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;
}
Q41Why 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, {
contexts: { react: { componentStack: info.componentStack } },
})
}
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
}
}
}
Q42Compare the styling approaches used in React codebases today: 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)
Q43What did React 19 actually change compared to React 18, and which long-standing APIs were removed?
BasicReact 19
Answer
React 19 (stable December 2024, followed by 19.1 in March 2025, 19.2 in October 2025 and 19.3 in September 2026) 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 (no longer needed; its deprecation has been announced for a future release). 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. React 19.3 then made <ViewTransition> stable, added Fragment refs, and let Server Components render a Context directly; mentioning one of those shows you track the release notes.
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 no longer needed (deprecation announced for a future release); ref callbacks can return cleanup
- Removed: propTypes, defaultProps (function comps), string refs, ReactDOM.render, findDOMNode
- 19.2 added <Activity> and useEffectEvent; 19.3 stabilised <ViewTransition> and Fragment refs; React Compiler 1.0 stable since October 2025
Q44How do you test a React component with Jest or Vitest and React Testing Library?
BasicTesting
Answer
The standard stack is a test runner (Jest, or Vitest in Vite projects, which shares the same API), React Testing Library (RTL) to render components into a jsdom document and query them the way a user would, @testing-library/user-event to simulate real interaction, and @testing-library/jest-dom for matchers like toBeInTheDocument. The guiding principle: test behaviour through the accessible UI (roles, labels, text), not implementation details like state variables or class names.
A test has three steps: render the component, interact (click, type), assert on what the user would see. Prefer getByRole with an accessible name; it fails loudly when a button has no label, which is also an accessibility bug. Use findBy for elements that appear after async work and queryBy only to assert absence. Wrap user interactions in await because user-event returns promises.
For components that fetch, mock the network at the boundary (msw or a mocked fetch), never the hook. For hooks on their own, use renderHook. Run tests in CI on every pull request; interviewers ask what you test, and 'the rendering of a happy path plus one failure state per component' is a credible answer.
// Counter.test.jsx (Vitest shown. Jest: drop the vitest import and use
// '@testing-library/jest-dom' instead of the '/vitest' entry)
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { expect, test } from 'vitest';
import '@testing-library/jest-dom/vitest';
import Counter from './Counter';
// Counter renders <button>Count: {count}</button> and increments on click
test('increments when the button is clicked', async () => {
const user = userEvent.setup();
render(<Counter />);
const button = screen.getByRole('button', { name: /count: 0/i });
expect(button).toBeInTheDocument();
await user.click(button);
expect(screen.getByRole('button', { name: /count: 1/i })).toBeInTheDocument();
});Key Points
- Runner (Jest/Vitest) + RTL + user-event + jest-dom matchers
- Query by role and accessible name; avoid testing implementation details
- getBy for present, findBy for async, queryBy for absence
- Mock the network at the boundary (msw), not your hooks
Q45Coding task: build a todo list with add, toggle and delete using immutable state updates.
BasicCoding Tasks
Answer
This is a common 20-minute machine-coding warm-up in fresher rounds, and it is judged on three things: controlled input handling, immutable updates (never push into or mutate the existing array), and stable keys. Keep the list in one useState array of objects with id, title and done. Add creates a new array with spread; toggle maps to a new array replacing just the matching item with a copied object; delete filters. Every update uses the functional form so rapid clicks cannot read a stale list.
Details that earn marks: trim the input and ignore empty submissions; use a form with onSubmit so Enter works and the button is type='submit'; generate ids with crypto.randomUUID() (available in every current browser on https or localhost, and in Node 19+) rather than the array index or Date.now() collisions; and put the checkbox inside a label so the whole text is clickable, which is also the accessible choice.
Follow-ups the interviewer will add in the remaining time: persist to localStorage (see the useLocalStorage task), add a filter (all, active, completed) derived during render rather than stored, and move the state into useReducer once there are five or more action types.
import { useState } from 'react';
export default function TodoApp() {
const [todos, setTodos] = useState([]);
const [text, setText] = useState('');
function addTodo(e) {
e.preventDefault();
const title = text.trim();
if (!title) return;
setTodos(prev => [...prev, { id: crypto.randomUUID(), title, done: false }]);
setText('');
}
function toggle(id) {
setTodos(prev =>
prev.map(todo => (todo.id === id ? { ...todo, done: !todo.done } : todo)),
);
}
function remove(id) {
setTodos(prev => prev.filter(todo => todo.id !== id));
}
return (
<>
<form onSubmit={addTodo}>
<input
value={text}
onChange={e => setText(e.target.value)}
placeholder="Add a task"
aria-label="New task"
/>
<button type="submit">Add</button>
</form>
<ul>
{todos.map(todo => (
<li key={todo.id}>
<label>
<input
type="checkbox"
checked={todo.done}
onChange={() => toggle(todo.id)}
/>
<span style={{ textDecoration: todo.done ? 'line-through' : 'none' }}>
{todo.title}
</span>
</label>
<button onClick={() => remove(todo.id)} aria-label={`Delete ${todo.title}`}>
Delete
</button>
</li>
))}
</ul>
</>
);
}Key Points
- One state array; add with spread, toggle with map, delete with filter
- Functional updates so rapid clicks never read a stale array
- Form onSubmit + controlled input; trim and ignore empty
- Stable ids (crypto.randomUUID), never the array index
Q46Coding task: write a component that fetches and displays a list of users with loading and error states.
BasicCoding Tasks
Answer
The interviewer is checking four things: you model the request as a status (loading, success, error) rather than two unrelated booleans; you check res.ok because fetch only rejects on network failure, not on a 404 or 500; you cancel the request on unmount or re-run with AbortController so a slow response cannot set state on a component that is gone; and you render all three states, including a visible error.
The code below does that with plain fetch in useEffect, which is what a fresher round expects. Say out loud that in a real codebase you would use TanStack Query (or RTK Query, or a framework loader) instead, because it adds caching, deduplication, retries and background refetching that this component does not have; it is the trade-off a two-years answer is expected to name.
Follow-ups to expect: add a retry button (re-run the effect by bumping a counter in state), add pagination (page in state, in the URL for shareability), and explain why StrictMode runs the effect twice in development and why the abort makes that harmless.
import { useEffect, useState } from 'react';
export default function Users() {
const [users, setUsers] = useState([]);
const [status, setStatus] = useState('loading'); // 'loading' | 'success' | 'error'
const [error, setError] = useState(null);
useEffect(() => {
const controller = new AbortController();
setStatus('loading');
fetch('https://jsonplaceholder.typicode.com/users', { signal: controller.signal })
.then(res => {
if (!res.ok) throw new Error(`Request failed with ${res.status}`);
return res.json();
})
.then(data => {
setUsers(data);
setStatus('success');
})
.catch(err => {
if (err.name === 'AbortError') return; // unmounted or re-run: ignore
setError(err);
setStatus('error');
});
return () => controller.abort();
}, []);
if (status === 'loading') return <p>Loading users...</p>;
if (status === 'error') return <p role="alert">Could not load users: {error.message}</p>;
return (
<ul>
{users.map(user => (
<li key={user.id}>
{user.name} ({user.email})
</li>
))}
</ul>
);
}Key Points
- Model status as one value: loading, success, error
- Check res.ok; fetch does not reject on HTTP error codes
- AbortController in cleanup prevents setState after unmount and races
- Mention TanStack Query as the production answer
Q47Output question: in what order do console.log calls in the component body, useLayoutEffect and useEffect run?
BasicOutput Questions
Answer
The component body runs first, top to bottom, during the render phase, so '1 render' and '2 render end' print in source order. Effects never run during render. After React commits the DOM, it runs all layout effects synchronously, before the browser paints, so '3 useLayoutEffect' prints next. Then the browser paints, and passive effects run, so '4 useEffect' prints last. The order in which you declared the two effects does not matter; their type decides when they run.
Two things interviewers add. In development under StrictMode, the body logs appear twice (React double-invokes render to surface impurity) and each effect mounts, cleans up and mounts again, so you see the layout and passive logs twice as well; production runs each once. And on updates, cleanups of the previous effects run before the new effects, so with a dependency change you would see the old cleanup logs before the new effect logs, layout effects first.
This question is a cheap way to find out whether you know the difference between render and commit.
import { useEffect, useLayoutEffect } from 'react';
function Demo() {
console.log('1 render');
useEffect(() => {
console.log('4 useEffect');
}, []);
useLayoutEffect(() => {
console.log('3 useLayoutEffect');
}, []);
console.log('2 render end');
return <p>Hi</p>;
}
// Production output:
// 1 render
// 2 render end
// 3 useLayoutEffect (after commit, before paint)
// 4 useEffect (after paint)
//
// Development + StrictMode: render logs twice, then each effect
// runs, cleans up and runs again.Key Points
- Body logs first, in source order (render phase)
- useLayoutEffect after commit, before paint; useEffect after paint
- Declaration order of effects does not change this
- StrictMode doubles renders and effect mount cycles in development
Q48Output question: why does this useEffect run infinitely, and how do you fix it?
BasicOutput Questions
Answer
An effect with no dependency array runs after every render. If the effect sets state, that schedules another render, which runs the effect again, which sets state again: an infinite loop (React skips the re-render only when the setter receives the exact same value by Object.is, and a new array or object reference never is, so the loop continues). The first snippet is exactly that: setItems creates a new array each time.
The second version has a dependency array, but the dependency is an object literal created during render, so by Object.is it is a 'new' value every time and the effect still runs on every render. The fix in both cases is the same: depend on the primitive values the effect actually reads, and compute derived state during render instead of inside an effect. If the derived value is expensive, useMemo it. If you truly need to react to an object, depend on its fields or memoise the object.
The pattern to say in the room: 'if an effect only transforms state into other state, it should not be an effect'. That answers the question and shows you have read the 'You Might Not Need an Effect' guidance.
// Bug 1: no dependency array, and the effect sets state
useEffect(() => {
setItems([...items, newItem]); // runs after every render, forever
});
// Bug 2: object literal in deps is a new reference every render
useEffect(() => {
fetchJobs(filters).then(setJobs);
}, [{ city: filters.city }]);
// Fix: depend on the primitives the effect reads
useEffect(() => {
const controller = new AbortController();
fetchJobs({ city }, controller.signal).then(setJobs).catch(() => {});
return () => controller.abort();
}, [city]);
// Better still for derived data: no effect at all
const visibleItems = useMemo(
() => items.filter(item => item.city === city),
[items, city],
);Key Points
- No deps + setState inside = render loop
- Object or array literals in deps change every render
- Depend on primitives; derive values in render, not in effects
- Effects are for syncing with external systems
Q49Output question: this setInterval counter gets stuck at 1. Why, and how do you fix it?
BasicOutput Questions
Answer
The effect runs once (empty dependency array) and creates an interval whose callback closed over the count from that first render, which is 0. Every tick calls setCount(0 + 1), so the display goes to 1 and stays there. This is a stale closure: the interval keeps a reference to an old render's variables. The lint rule would have flagged count as a missing dependency.
Three fixes, in order of preference. Use the functional updater, setCount(c => c + 1), which does not read count from the closure at all; this is the right answer for the interview. Add count to the dependency array, which recreates the interval every second (correct but wasteful, and timing drifts). Or, in React 19.2 and later, wrap the tick in useEffectEvent so the effect still runs once while the callback always sees the latest state.
The interviewer may extend this to 'why does a click handler see a stale value after an async wait' (same mechanism: handlers are closures over their render) and to the setCount(count + 1) twice question, so have both ready.
import { useEffect, useState } from 'react';
function Timer() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1); // count is always 0 inside this closure
}, 1000);
return () => clearInterval(id);
}, []);
return <p>{count}</p>; // shows 1 and never moves
}
// Fix: the updater form reads the latest state from React, not the closure
function TimerFixed() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => setCount(c => c + 1), 1000);
return () => clearInterval(id);
}, []);
return <p>{count}</p>;
}Key Points
- Interval callback closed over count from the first render
- setCount(count + 1) always computes 0 + 1
- Fix with setCount(c => c + 1); or deps; or useEffectEvent (19.2+)
- Same mechanism behind stale values in async handlers
Q50Output question: you call setCount(5) and then console.log(count) on the next line. What is logged, and why?
BasicOutput Questions
Answer
It logs the old value (0 on the first click), not 5. Calling the setter does not change the count variable in the running function; it queues an update and asks React to re-render. The count you are reading is a constant captured when this render ran, a snapshot. The new value exists only in the next render's count. The setTimeout in the example logs 0 too, even a second later, because it belongs to the same closure.
The interviewer wants three follow-ups. 'How do I run code with the new value?' Put it in an effect that depends on count, or compute what you need from the value you already have (you know it is 5, use 5). 'Is setState asynchronous?' The setter call is synchronous, but the re-render is scheduled and batched: all setter calls in one event handler, timeout or promise callback produce a single render (automatic batching since React 18), which is why reading state right after setting it never reflects the update. 'Does flushSync change this?' flushSync forces the render to happen before the next line, but count in the current closure is still the old constant; only the DOM and the next render see the new value.
import { useEffect, useState } from 'react';
function Demo() {
const [count, setCount] = useState(0);
function handleClick() {
setCount(5);
console.log(count); // 0: this render's snapshot
setTimeout(() => console.log(count), 1000); // still 0: same closure
}
// Runs after the re-render that has count === 5
useEffect(() => {
if (count === 5) console.log('now', count); // 'now 5'
}, [count]);
return <button onClick={handleClick}>{count}</button>;
}Key Points
- State is a snapshot per render; setters do not mutate the variable
- Updates are batched into one re-render per event (React 18+)
- Read the new value in the next render or in an effect
- flushSync forces the render, not the closure's variable
Q51Which React interview questions are asked for freshers, for 2 to 3 years of experience, and for senior roles?
BasicPreparation
Answer
Freshers and campus rounds (0 to 1 year) get definitions with one follow-up each: what React is and its features, JSX and its rules, props versus state, the Virtual DOM, lifecycle and how hooks replace it, useState, useEffect and the dependency array, useRef, keys in lists, controlled forms, Context versus Redux basics, React Router basics, and one or two coding tasks (a todo list, a fetch-and-display list) plus an output question on stale state. Clear the basic section of this page and you cover it.
Two to three years of experience gets the 'why' questions and a 45 to 60 minute machine-coding round: when memoisation helps and when it hurts, React.memo and children, useReducer design, stale closures and useEffectEvent, useLayoutEffect, Suspense and useTransition, TanStack Query versus fetch-in-effect, Zustand versus Redux Toolkit, testing with RTL, TypeScript props and generics, hydration errors, accessibility of a modal, plus a build-it-live task such as a debounced typeahead, infinite scroll or a kanban board. That is the intermediate section.
Senior and lead rounds (5+ years) spend less time on APIs and more on judgement: Fiber and lanes, Server Components and Server Functions, the React Compiler, streaming SSR, useSyncExternalStore and tearing, memory leaks in long-lived SPAs, XSS despite JSX escaping, micro-frontends, state architecture for 50 engineers, and bringing INP under 200 ms on mid-range Android. That is the advanced section, and the questions come with 'what did you ship' follow-ups.
Key Points
- Freshers: definitions, hooks basics, keys, forms, router, one coding task
- 2 to 3 years: trade-offs, performance, data fetching, testing, machine coding
- Senior: internals, RSC, compiler, SSR streaming, architecture, production debugging
- Every level: at least one output question on stale closures or effects
Q52How do you prepare for a React interview? A four-week plan
BasicPreparation
Answer
Knowing the answers is half the job; the other half is producing them under time pressure in a shared editor. This plan assumes one to two hours a day and works for freshers in two weeks (do weeks one, two and four) and for experienced candidates in four.
Week one is JavaScript and React fundamentals, because weak JavaScript sinks strong React knowledge: closures, promises and async/await, array methods, the event loop, then the basic section of this page with every snippet typed out, not read. Week two is hooks and data: useState snapshots, every useEffect variant, useRef, useReducer, custom hooks, the four output questions, and fetching with cancellation, then the same with TanStack Query. Week three is the intermediate band: memoisation and the compiler, Context versus stores, React Router, testing with RTL, TypeScript props, and two timed machine-coding tasks a week (typeahead, infinite scroll, kanban, star rating, OTP input). Week four is rehearsal: say answers aloud, record yourself, run mock interviews, and read the advanced section so senior-sounding words (Fiber, RSC, hydration, INP) are not new in the room.
Throughout: keep two or three small projects on GitHub you can walk through line by line, and practise explaining a bug you actually fixed. Consistency beats cramming; two focused hours a day for two weeks beats one exhausting weekend.
Key Points
- Week 1: JavaScript fundamentals + React basics, every snippet typed
- Week 2: hooks deep dive, output questions, fetching with cancellation
- Week 3: performance, state management, routing, testing, timed coding tasks
- Week 4: speak answers aloud, mock interviews, skim the advanced band
- Keep 2 to 3 GitHub projects you can explain line by line
Q53How does React's diffing algorithm (reconciliation) work, and how do keys change it?
IntermediateRendering
Answer
Reconciliation is how React works out what changed between the previous element tree and the new one so it can update only those parts of the real DOM. It compares elements by type and position, matches list children by key, and turns the differences into a minimal set of DOM operations during the commit phase.
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 /> does not remount Dashboard when isAdmin flips, because the false value still holds the first child slot; but isAdmin ? <><AdminPanel /><Dashboard /></> : <><Dashboard /></> does, because Dashboard moves from the second slot to the first. 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
Q54Why 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 />; // BROKEN: the hook below is skipped when !userId
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} />;
}
Q55What 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} />;
}
Q56useLayoutEffect versus useEffect: when does the difference actually matter, and why is useLayoutEffect a trap under SSR?
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 normally runs after paint (React may flush it before paint when a discrete input such as a click caused the update, so never rely on it for visual fixes). 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 (React 18 logged a warning for useLayoutEffect in server-rendered components; React 19 removed the warning and silently skips it), so 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.
import { useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
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]); // measures once on mount; in production also re-run on resize/scroll
return createPortal(
<div ref={tipRef} style={{ position: 'fixed', ...pos }}>
{children}
</div>,
document.body
);
}
Q57When 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 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' };
default: {
const _exhaustive: never = action; // compile error if a case is missing
throw new Error(`Unknown action: ${JSON.stringify(_exhaustive)}`);
}
}
}
const [state, dispatch] = useReducer(reducer, initialState);
// dispatch({ type: 'field_changed', field: 'otp', value: '482913' });
Q58React 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 no longer needed (deprecation announced for a future release; it still works, 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);
}} />;
Q59When 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. 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 React Compiler changes this: 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.
import { memo, useCallback, useMemo, useState } from 'react';
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: it closes over sortKey, so without useCallback it would be
// a new function every render and defeat Row's memo()
const handleSelect = useCallback(
id => onSelect(id, sortKey),
[onSelect, sortKey],
);
return (
<table>
<tbody>
{sorted.map(c => (
<Row key={c.id} candidate={c} onSelect={handleSelect} />
))}
</tbody>
</table>
);
}
const Row = memo(function Row({ candidate, onSelect }) {
return (
<tr onClick={() => onSelect(candidate.id)}>
<td>{candidate.name}</td>
<td>{candidate.score}</td>
</tr>
);
});
Q60What is React.memo, how does it decide 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 sparingly 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} />
))}
</>
);
}
Q61How do you optimize the performance of a React application?
IntermediatePerformance
Answer
Start by measuring, not guessing: React DevTools Profiler shows which components render and why, Chrome's Performance panel (with React 19.2's Scheduler and Components tracks) shows long tasks, and Lighthouse or field data gives LCP and INP. Then fix the biggest cost first. Most React performance problems fall into five buckets.
Too many re-renders: keep state as local as possible, split large components so a keystroke does not re-render a whole page, memoise with React.memo, useMemo and useCallback where props cross a boundary (or enable the React Compiler and delete most of that by hand), and avoid inline objects as props to memoised children. Too much JavaScript: code-split by route with React.lazy and Suspense, load heavy widgets on interaction, tree-shake libraries (import what you use from lodash-es, date-fns), and move static UI to Server Components in Next.js. Too much DOM: virtualise lists above a few hundred rows with TanStack Virtual, paginate, and avoid deep wrapper trees. Slow data: cache and deduplicate with TanStack Query, prefetch on hover, stream SSR so the shell paints before data arrives. Slow interactions: mark non-urgent updates with useTransition, use useDeferredValue for expensive filtered views, debounce network calls, and keep handlers under 50 ms so INP stays under 200 ms on mid-range Android.
Bundle hygiene (analyse with the bundle analyser, keep one copy of React), proper image sizing and a web-font strategy round out the list. Interviewers want the order: measure, find the bottleneck, apply the matching tool, re-measure.
Key Points
- Measure first: Profiler, Performance panel, Lighthouse, field INP and LCP
- Re-renders: local state, split components, memo/useMemo/useCallback or the compiler
- JavaScript: route code splitting, lazy widgets, tree shaking, Server Components
- DOM and data: virtualise long lists, cache and dedupe with TanStack Query, stream SSR
- Interactions: useTransition, useDeferredValue, debounce, keep handlers short
Q62How 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 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, useState } 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>
</>
);
}
Q63When 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 { useRef } from 'react';
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>
);
}
Q64A 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, which is closer to a mid-range Android phone, 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
Q65Output question: this child is wrapped in React.memo but still re-renders on every parent render. Why?
IntermediateOutput Questions
Answer
React.memo skips a re-render only when every prop is equal by Object.is to the previous render's prop. In the snippet, the parent creates a new object ({ ...user }) and a new arrow function for onSelect on every render, so both props are new references every time, the shallow comparison fails, and 'Row render' logs for every row on every click. memo is working exactly as designed; the props are not stable.
Fix the data prop by passing user as it is (the array element's reference is stable unless the array is rebuilt) and the callback by hoisting it with useCallback, with setSelected as its only dependency (state setters are stable, so an empty array also passes the lint rule). If the callback needs the id, pass a stable onSelect that takes the id and let Row build the closure internally, as the fixed version does. Children are the third usual culprit: passing JSX children creates a new element object each render, so memo on a wrapper with children rarely helps.
Say in the interview that the React Compiler removes this entire class of bug by memoising the object and the callback automatically when the component follows the Rules of React, and that in a pre-compiler codebase you verify the fix with the Profiler's 'why did this render' panel rather than by trusting the code.
import { memo, useCallback, useState } from 'react';
const Row = memo(function Row({ user, onSelect }) {
console.log('Row render', user.id);
return <li onClick={() => onSelect(user.id)}>{user.name}</li>;
});
// BROKEN: memo compares props by reference, and both props are new each render
function List({ users }) {
const [selected, setSelected] = useState(null);
return (
<ul>
{users.map(user => (
<Row
key={user.id}
user={{ ...user }} // new object every render
onSelect={id => setSelected(id)} // new function every render
/>
))}
<li>Selected: {selected}</li>
</ul>
);
}
// FIXED: stable references, so memo can skip unchanged rows
function ListFixed({ users }) {
const [selected, setSelected] = useState(null);
const onSelect = useCallback(id => setSelected(id), []);
return (
<ul>
{users.map(user => (
<Row key={user.id} user={user} onSelect={onSelect} />
))}
<li>Selected: {selected}</li>
</ul>
);
}Key Points
- memo uses shallow comparison by reference (Object.is) per prop
- Inline objects and arrow functions are new references every render
- Fix: pass stable references; useCallback for handlers
- JSX children also break memo; the React Compiler automates the fix
Q66Zustand 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 value the action log for auditability), 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);
Q67What is Redux middleware, and when do you use Thunk, Saga or RTK Query?
IntermediateState Management
Answer
Middleware is a function chain between dispatch(action) and the reducers. Each middleware sees every action, can log it, drop it, delay it, dispatch others, or run async work, and then passes it on. Reducers must stay pure, so anything with side effects (API calls, timers, analytics) lives in middleware. Redux Toolkit's configureStore installs redux-thunk and a few development checks (serialisability, accidental mutation) by default.
Thunk lets you dispatch a function instead of an object; the function receives dispatch and getState and can await an API call before dispatching the result. It is simple, covers most apps, and is what createAsyncThunk generates. Saga uses generator functions to describe long-running flows (debounce a search, cancel a stale request, retry three times, coordinate a multi-step checkout) declaratively; it is powerful but heavy, and new projects rarely add it. RTK Query is a data-fetching layer built into Redux Toolkit: you declare endpoints, it generates hooks like useGetJobsQuery with caching, deduplication, automatic refetching and cache invalidation through tags, so server data stops needing hand-written thunks at all.
The decision rule: RTK Query for server data, thunks for the odd imperative async flow, Saga only when you inherit it or need complex cancellation choreography.
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
// Thunk: a function instead of an action object
export const fetchJobs = () => async (dispatch, getState) => {
dispatch(jobsLoading());
try {
const res = await fetch('/api/jobs');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
dispatch(jobsLoaded(await res.json()));
} catch (err) {
dispatch(jobsFailed(err.message));
}
};
// RTK Query: declare the endpoint, get a hook with caching and invalidation
export const jobsApi = createApi({
reducerPath: 'jobsApi',
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
tagTypes: ['Job'],
endpoints: build => ({
getJobs: build.query({
query: () => '/jobs',
providesTags: ['Job'],
}),
addJob: build.mutation({
query: body => ({ url: '/jobs', method: 'POST', body }),
invalidatesTags: ['Job'], // refetch getJobs after a successful add
}),
}),
});
export const { useGetJobsQuery, useAddJobMutation } = jobsApi;
// store: reducer: { [jobsApi.reducerPath]: jobsApi.reducer }, middleware: gDM => gDM().concat(jobsApi.middleware)Key Points
- Middleware sits between dispatch and reducers; side effects live there
- Thunk: dispatch a function with (dispatch, getState); simple, default in RTK
- Saga: generator-based flows for cancellation and orchestration; heavy
- RTK Query: generated hooks with caching and tag invalidation for server data
Q68In 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>
Q69How do loaders and actions in React Router v7 change data fetching compared to useEffect-based fetching in components?
IntermediateRouting
Answer
React Router v7 and v8 (which absorbed Remix) run 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>
);
}
Q70How does Suspense actually work for data fetching, and what does the use() API change in React 19?
IntermediateConcurrent React
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.
// app/jobs/[jobId]/page.jsx: Server Component. Start the fetch, do NOT await it.
import { Suspense } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { Reviews } from './Reviews';
// fetchReviews, ReviewsSkeleton, ReviewsError: your own
export default async function JobPage({ params }) {
const { jobId } = await params; // params is a Promise in Next.js 15+
const reviewsPromise = fetchReviews(jobId); // kicked off early, streams later
return (
<ErrorBoundary fallback={<ReviewsError />}>
<Suspense fallback={<ReviewsSkeleton />}>
<Reviews reviewsPromise={reviewsPromise} />
</Suspense>
</ErrorBoundary>
);
}
// app/jobs/[jobId]/Reviews.jsx: Client Component, unwraps the promise with use()
'use client';
import { use } from 'react';
export function Reviews({ reviewsPromise }) {
const reviews = use(reviewsPromise); // suspends until resolved
return (
<ul>
{reviews.map(r => (
<li key={r.id}>{r.text}</li>
))}
</ul>
);
}
Q71What does concurrent rendering actually mean in React, and what specifically does useTransition change about an update?
IntermediateConcurrent React
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.
One trap to say aloud: startTransition calls its callback synchronously, so only the state update belongs inside it. Do the expensive computation during the render that update triggers (the example uses useMemo keyed on the transition state), not inside the callback, or the keystroke handler blocks for the full cost and nothing is interruptible.
import { useMemo, useState, useTransition } from 'react';
function CandidateSearch({ allCandidates }) {
const [query, setQuery] = useState(''); // urgent: echoes every keystroke
const [filterQuery, setFilterQuery] = useState(''); // transition: may lag behind
const [isPending, startTransition] = useTransition();
function handleChange(e) {
const next = e.target.value;
setQuery(next);
startTransition(() => {
// Only the state change goes in the transition. startTransition runs this
// callback synchronously, so heavy work here would block the keystroke.
setFilterQuery(next);
});
}
// The expensive work runs in the low-priority render the transition
// triggers, which React can interrupt and restart on the next keystroke.
const results = useMemo(
() => expensiveFilter(allCandidates, filterQuery),
[allCandidates, filterQuery],
);
return (
<>
<input value={query} onChange={handleChange} />
<div style={{ opacity: isPending ? 0.6 : 1 }}>
<ResultsList results={results} />
</div>
</>
);
}
Q72useDeferredValue versus debouncing: what is the difference in mechanism, and when is each the right tool?
IntermediateConcurrent React
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} />);
});
Q73How does useOptimistic implement optimistic UI in React 19, and how does it roll back on failure?
IntermediateConcurrent React
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>
</>
);
}
Q74What 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 getAllBy, queryAllBy and findAllBy 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(...)); do not assert synchronously on UI that appears only after a promise resolves. 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();
});
Q75How 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'));
});
Q76How 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. In React 19's types useRef always needs an argument and .current is always mutable: useRef<HTMLInputElement>(null) gives RefObject<HTMLInputElement | null> for DOM refs, and useRef<number>(0) or useRef<AbortController | null>(null) work as mutable containers; the old read-only RefObject versus MutableRefObject split is gone. 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}>
<button onClick={() => setOpen(v => !v)}>Pick</button>
{open &&
options.map(o => (
<div key={getLabel(o)} onClick={() => onPick(o)}>
{getLabel(o)}
</div>
))}
</div>
);
}
Q77Build 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
Q78What 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 Priya' 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
Q79What 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 to avoid colliding with hand-written IDs: React 19.2 changed the prefix to underscores (like '_r_1_', '_R_2kb_') so the ids are valid in CSS selectors and in view-transition-name, replacing the colon-wrapped ':r1:' format older versions produced. 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.
Q80What is the difference between the App Router and the Pages Router in Next.js?
IntermediateNext.js
Answer
The Pages Router (pages/ directory, Next.js 9 to 12 era, still supported) maps each file to a route, renders every page component on the client as well as the server, and fetches data through page-level functions: getServerSideProps for per-request SSR, getStaticProps and getStaticPaths for static generation, getInitialProps in older code. Layouts are hand-rolled through _app.js, and all component code ships to the browser.
The App Router (app/ directory, stable since Next.js 13.4 and the default in create-next-app since then) is built on React Server Components. Every component is a Server Component unless it starts with 'use client', so data fetching is just await inside an async component, no special functions; layouts nest through layout.tsx and keep state across navigations; loading.tsx and error.tsx wire Suspense and error boundaries per route segment; route handlers replace API routes; Server Actions handle mutations from forms; and streaming is the default. Next.js 16 adds Cache Components ('use cache') on top, making caching explicit.
What interviewers probe: you cannot use hooks or browser APIs in a Server Component (mark the leaf interactive component 'use client' instead); props crossing the client boundary must be serialisable; and migration is incremental because both routers can coexist in one app. In 2026 new projects start on the App Router; Pages Router questions come from companies with older codebases.
Key Points
- Pages Router: file routes + getServerSideProps / getStaticProps; all code ships to client
- App Router: Server Components by default, async data fetching, nested layouts, streaming
- 'use client' marks interactive leaves; props across the boundary must serialise
- Both can coexist; new work goes on the App Router
Q81How does data fetching work in the Next.js App Router, and what changed with Cache Components in Next.js 16?
IntermediateNext.js
Answer
In the App Router a page or layout can be an async Server Component: you await fetch (or a database call) directly in the component, the data never leaves the server, and the rendered HTML streams to the client. Next.js extends fetch with caching hints: { cache: 'force-cache' } to reuse a response across requests, { next: { revalidate: 60 } } for time-based revalidation (incremental static regeneration), and { cache: 'no-store' } for always-fresh data. Mutations go through Server Actions ('use server' functions called from forms or event handlers), and revalidatePath or revalidateTag refreshes the cached data afterwards. Client-side fetching with TanStack Query or SWR is still right for data that changes while the page is open.
Next.js 16 introduced Cache Components, enabled with cacheComponents: true in next.config. With it on, the model flips to explicit: nothing is cached unless you mark it with the 'use cache' directive on a component, function or file, and cacheLife and cacheTag control how long the cache lives and how it is invalidated. Dynamic parts of a page are wrapped in Suspense and streamed, while the cached shell is served instantly (partial pre-rendering), so a page can be mostly static with one live widget without choosing between SSG and SSR for the whole route.
The interview-grade summary: Server Components fetch on the server, caching used to be implicit and surprising, and Next.js 16 made it opt-in and visible in the code.
// app/jobs/page.tsx: an async Server Component, no useEffect, no client bundle for this
export default async function JobsPage() {
const res = await fetch('https://api.example.com/jobs', {
next: { revalidate: 60 }, // re-render in the background at most once a minute
});
const jobs: Array<{ id: string; title: string }> = await res.json();
return (
<ul>
{jobs.map(job => (
<li key={job.id}>{job.title}</li>
))}
</ul>
);
}
// Next.js 16 Cache Components (next.config: cacheComponents: true)
// Only what you mark is cached; the rest renders dynamically and streams.
import { cacheLife, cacheTag } from 'next/cache';
export async function getJobs() {
'use cache';
cacheLife('minutes');
cacheTag('jobs'); // after a mutation: updateTag('jobs') in a Server Action, or revalidateTag('jobs', 'max')
const res = await fetch('https://api.example.com/jobs');
return res.json();
}Key Points
- Async Server Components await data directly; HTML streams to the client
- fetch options: force-cache, next.revalidate, no-store; Server Actions for mutations
- Next.js 16 Cache Components: opt-in 'use cache' with cacheLife and cacheTag
- Client fetching (TanStack Query, SWR) still right for live, per-user data
Q82Coding task: build a search box with debounced API calls (typeahead).
IntermediateCoding Tasks
Answer
The typeahead is one of the most common machine-coding tasks for two to four years of experience in India, and it tests four things at once: a controlled input, debouncing so the API is not hit on every keystroke, cancelling stale requests so a slow response for 'rea' cannot overwrite the results for 'react', and accessible markup for the results list.
The solution combines the useDebouncedValue hook from earlier with an effect keyed on the debounced value. Each run creates an AbortController and aborts it in the cleanup, so when the debounced value changes the previous request is cancelled rather than raced. Short queries are skipped, loading state is shown, and an AbortError is ignored because it is expected. The input carries the combobox pattern attributes so screen readers announce the list.
Follow-ups to prepare: keyboard navigation with ArrowUp, ArrowDown and Enter (keep an activeIndex in state), caching results per query in a Map ref so backspacing is instant, highlighting the matched substring, and what you would change with TanStack Query (useQuery with the debounced value as the key gives you caching and cancellation for free).
import { useEffect, useState } from 'react';
import { useDebouncedValue } from './useDebouncedValue';
export default function JobSearch() {
const [query, setQuery] = useState('');
const debounced = useDebouncedValue(query, 350);
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
const term = debounced.trim();
if (term.length < 2) {
setResults([]);
setLoading(false); // a cancelled in-flight request must not leave 'Searching...' on screen
return;
}
const controller = new AbortController();
setLoading(true);
setError(null);
fetch(`/api/jobs?q=${encodeURIComponent(term)}`, { signal: controller.signal })
.then(res => {
if (!res.ok) throw new Error(`Search failed with ${res.status}`);
return res.json();
})
.then(data => {
setResults(data);
setLoading(false);
})
.catch(err => {
if (err.name === 'AbortError') return; // superseded by a newer query
setResults([]);
setError(err.message);
setLoading(false);
});
return () => controller.abort(); // cancel the stale request
}, [debounced]);
return (
<div>
<input
role="combobox"
aria-expanded={results.length > 0}
aria-controls="job-results"
aria-autocomplete="list"
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search jobs"
/>
{loading && <span>Searching...</span>}
{error && <p role="alert">{error}</p>}
<ul id="job-results" role="listbox">
{results.map(job => (
<li key={job.id} role="option" aria-selected={false}>
{job.title}
</li>
))}
</ul>
</div>
);
}Key Points
- Debounce the value, then run the effect on the debounced value
- AbortController in cleanup cancels stale requests; ignore AbortError
- Skip short queries; show loading; keep results in state
- Follow-ups: keyboard navigation, per-query cache, TanStack Query
Q83Coding task: implement infinite scroll with IntersectionObserver.
IntermediateCoding Tasks
Answer
Infinite scroll appends the next page when the user nears the bottom. The naive version listens to window scroll events and measures scrollHeight on every tick, which is expensive and janky on mid-range phones. The right tool is IntersectionObserver: render an empty sentinel element after the list, observe it, and when it enters the viewport (with a rootMargin so loading starts before the user actually reaches the end) bump the page number. A second effect fetches whenever page changes and appends.
The details that are graded: append with the functional updater; gate the page bump behind a loading flag so two callbacks during one fetch cannot skip a page; re-create the observer after each append, otherwise a short first page that never pushes the sentinel out of view would never trigger page two; keep a hasMore flag from the response so you stop observing at the end; ignore responses that arrive after the effect was cleaned up (StrictMode runs it twice in development, which would otherwise duplicate page one); and disconnect the observer in cleanup. Keys must come from the item ids, because the list only grows.
Follow-ups: show a skeleton while loading, handle errors with a retry button, use TanStack Query's useInfiniteQuery (which owns pages, hasNextPage and fetchNextPage for you), and virtualise once thousands of rows are mounted, because infinite scroll without windowing eventually makes the DOM itself the bottleneck.
import { useEffect, useRef, useState } from 'react';
const PAGE_SIZE = 20;
export default function JobFeed() {
const [jobs, setJobs] = useState([]);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const loadingRef = useRef(false); // gates the observer so one fetch = one page bump
const sentinelRef = useRef(null);
// Fetch whenever page changes; ignore results after cleanup
useEffect(() => {
let cancelled = false;
loadingRef.current = true;
fetch(`/api/jobs?page=${page}&limit=${PAGE_SIZE}`)
.then(res => res.json())
.then(data => {
if (cancelled) return;
setJobs(prev => [...prev, ...data.items]);
setHasMore(data.items.length === PAGE_SIZE);
})
.finally(() => {
if (!cancelled) loadingRef.current = false;
});
return () => {
cancelled = true;
};
}, [page]);
// Observe the sentinel. Re-created after every append so a short first page
// (sentinel still on screen) keeps loading until the viewport is full.
useEffect(() => {
const node = sentinelRef.current;
if (!node || !hasMore) return;
const observer = new IntersectionObserver(
entries => {
if (entries[0].isIntersecting && !loadingRef.current) setPage(p => p + 1);
},
{ rootMargin: '200px' }, // start loading before the user hits the bottom
);
observer.observe(node);
return () => observer.disconnect();
}, [hasMore, jobs.length]);
return (
<>
<ul>
{jobs.map(job => (
<li key={job.id}>{job.title}</li>
))}
</ul>
{hasMore ? <div ref={sentinelRef}>Loading more...</div> : <p>No more jobs</p>}
</>
);
}Key Points
- Sentinel element + IntersectionObserver, not scroll listeners
- page in state drives the fetch; append with the functional updater
- loading flag gates the bump; observer re-created after each append; hasMore ends it
- Production: useInfiniteQuery, skeletons, then virtualisation
Q84Coding task: write a useLocalStorage hook that is safe under server rendering.
IntermediateCoding Tasks
Answer
useLocalStorage behaves like useState but persists the value under a key. The naive version reads localStorage inside the lazy initialiser of useState, which is fine in a client-only Vite app but fails under server rendering twice over: the server has no window, and during hydration the client's first render would read a stored value that differs from the server HTML, so React logs a hydration mismatch.
The SSR-safe version uses useSyncExternalStore, which exists for exactly this. getSnapshot reads storage on the client (a string, so Object.is comparison is stable); getServerSnapshot returns null so the server render and the hydrating render both show the initial value; subscribe listens to the storage event so other tabs stay in sync. After hydration React re-renders with the client snapshot without an error. Writes go through a setter that updates storage and then dispatches a storage event, because the browser only fires that event in other tabs. Reads and writes sit in try/catch because storage can be full or blocked (Safari private mode, some corporate browsers).
Follow-ups: namespace keys per user so one account's preferences never leak into another, add a version field so a schema change can migrate old values, and explain when the simpler lazy-initialiser version is acceptable.
import { useCallback, useMemo, useSyncExternalStore } from 'react';
function subscribe(onChange) {
window.addEventListener('storage', onChange);
return () => window.removeEventListener('storage', onChange);
}
function parse(raw, fallback) {
if (raw === null) return fallback;
try {
return JSON.parse(raw);
} catch {
return fallback; // corrupt or hand-edited value
}
}
export function useLocalStorage(key, initialValue) {
const raw = useSyncExternalStore(
subscribe,
() => {
try {
return window.localStorage.getItem(key); // client snapshot
} catch {
return null; // storage blocked
}
},
() => null, // server snapshot: no window, render the initial value
);
const value = useMemo(() => parse(raw, initialValue), [raw, initialValue]);
const setValue = useCallback(
next => {
let resolved = next;
if (typeof next === 'function') {
// Read the latest stored value, not this render's snapshot, so two
// updater calls in one handler compose the way useState's do
let current = null;
try {
current = window.localStorage.getItem(key);
} catch {
current = null;
}
resolved = next(parse(current, initialValue));
}
try {
window.localStorage.setItem(key, JSON.stringify(resolved));
} catch {
return; // quota exceeded or storage disabled: nothing persisted
}
// setItem does not fire 'storage' in the same tab, so notify subscribers here
window.dispatchEvent(new StorageEvent('storage', { key }));
},
[key, initialValue],
);
return [value, setValue];
}
// const [theme, setTheme] = useLocalStorage('theme', 'light');
// Server HTML and the hydrating render both show 'light'; the stored value
// appears in the re-render right after hydration, with no mismatch error.Key Points
- useSyncExternalStore with a null server snapshot avoids the hydration mismatch
- subscribe on the storage event; dispatch it yourself after setItem
- try/catch every read and write; storage can be full or blocked
- Lazy-initialiser version is fine only in client-only apps
Q85Explain 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
Q86How 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'), or class instances (Date, Map, Set and plain objects are fine). 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 offers RSC support only as an unstable, experimental API), 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';
import { SalaryBreakdown } from './SalaryBreakdown';
export default async function JobPage({ params }) {
const { id } = await params; // params is a Promise in Next.js 15+
const job = await db.job.findUnique({ where: { 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}
</>
);
}
Q87How 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 16 you call revalidatePath, updateTag (read-your-own-writes inside a Server Action) or revalidateTag(tag, profile), which now takes a cacheLife profile as its second argument, 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';
import { db } from '@/lib/db';
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>
Q88What 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 plugin that Next.js and Vite wire in, 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 fix a wrong useEffect dependency array, and code that lied to React (mutating state, abusing refs during render) may need fixing before the compiler will touch it. Meta has shipped it in production apps such as the Meta Quest Store. 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'
Q89A 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
Q90What 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, react-redux and TanStack Query all sit on this hook internally (Jotai is the exception: it subscribes with useReducer and useEffect); 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);
},
};
}
Q91How 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) => {
let didError = false;
const { pipe, abort } = renderToPipeableStream(
<App url={req.url} />,
{
bootstrapScripts: ['/assets/main.js'],
onShellReady() {
// Shell done: fallbacks in place for pending boundaries
res.statusCode = didError ? 500 : 200;
res.setHeader('Content-Type', 'text/html');
pipe(res); // start streaming NOW
},
onShellError(err) {
res.statusCode = 500;
res.setHeader('Content-Type', 'text/html');
res.send('<!doctype html><p>Something went wrong</p>');
},
onError(err) {
didError = true;
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>
Q92What does the <Activity> component in React 19.2 do, and which long-standing problems does it solve?
AdvancedReact 19
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.
React 19.3 (September 2026) stabilised <ViewTransition>; naming both Activity and ViewTransition shows your knowledge is current.
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>
</>
);
}
Q93Where 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 19 blocks javascript: URLs in href, src and action attributes (React 18 only warned), but data:, vbscript: and blob: URLs pass, and imperative sinks such as window.open, location.href and iframe srcdoc bypass React entirely, so you must still 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: React 19 blocks javascript:, but data:, vbscript:
// and blob: still get through, and window.open / location.href never see React
function SafeLink({ href, children }) {
let safe = '#';
try {
// The base only resolves relative hrefs, and avoids touching window on the server
const url = new URL(href, 'https://example.com');
if (['http:', 'https:', 'mailto:'].includes(url.protocol)) {
safe = href; // keep the original so relative links stay on your own site
}
} catch { /* malformed: keep '#' */ }
return (
<a href={safe} rel='noopener noreferrer'>
{children}
</a>
);
}
// NEVER: <div {...apiResponse} /> (can smuggle dangerouslySetInnerHTML)
Q94How 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
Q95Design 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
Q96Your 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 do not reflect budget Android phones. 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 { useState, useTransition } from 'react';
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>
);
}
Frequently Asked Questions
What is the latest version of React?
React 19.3, released on 9 September 2026, is the current stable line. It stabilised the ViewTransition component, added Fragment refs and let Server Components render a Context directly. React 19.2 (October 2025) added the Activity component and useEffectEvent, and the React Compiler reached its stable 1.0 release the same month. Interviewers rarely ask for version numbers directly, but they do ask what changed in React 19 compared with 18, so know Actions, use(), ref-as-a-prop and the removed APIs.
How many React questions should a fresher prepare?
Be comfortable with the basic section here, roughly fifty questions across basics, hooks, the Virtual DOM, events and forms, Context and Redux basics, routing and the four output questions. Depth on the fundamentals matters more than breadth: an interviewer would rather see you explain why setCount(count + 1) twice adds one than hear thirty memorised definitions. Add two coding tasks you can build from a blank file in twenty minutes.
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; a fresher can cover the basic band in two. Follow the four-week plan in the Preparation questions above and keep the last week for speaking answers aloud and timed coding tasks.
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.
Do I need to know Redux to clear a React interview?
For freshers, understanding the Context API and the basic Redux model (store, actions, reducers, and that Redux Toolkit is how it is written today) is usually enough. Roles asking for two or more years often expect hands-on Redux Toolkit, including createSlice, thunks or RTK Query, and a clear answer on when you would pick it over Context, Zustand or TanStack Query. Many product-company codebases run without Redux at all, so the judgement about when it is needed matters more than API recall.
Is class component knowledge still required in 2026?
New React code is written with function components and hooks, and the React Compiler and Server Components only work with function components. But many companies maintain codebases started before 2019, and error boundaries still require a class, so you should be able to read a class component, explain its lifecycle methods, map them to hooks, and convert one when asked. Expect such a question more often at companies with older codebases than at startups that started on hooks.
How important are coding and output questions in a React interview?
Very. They separate candidates who memorised answers from those who understand how React renders. Expect at least one output question on stale closures, effect dependencies or batching in almost every serious interview, and at least one build-it-live task (todo list, fetch-and-display, typeahead, infinite scroll) from mid-level onwards. The output and coding questions on this page are the kinds that come up most often in Indian product-company and service-company rounds.
How much does a React developer earn in India in 2026?
It depends on experience, city and company tier more than on React itself: freshers at service companies earn the least, engineers at product companies and global capability centres who also own Next.js, TypeScript and performance work earn the most, and full-stack roles pay above frontend-only roles at the same level. Check the live React openings on GoodSpace for current figures for your experience band rather than relying on a single average.
Is React still worth learning in 2026, or should I bet on something newer?
React is a safe frontend bet in India: it appears in frontend job descriptions 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 appear in few Indian job descriptions; 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 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 many 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 (many consumer and fintech apps run React Native screens), and experienced RN engineers are harder to find 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.
What is the best way to practise React answers out loud?
Mock interviews. Explaining a concept aloud exposes gaps that silent reading hides, and it trains you to stay structured under pressure. Pair with a friend and swap roles, record yourself answering the output questions on this page, or use the GoodSpace AI mock interview, which asks React questions with follow-ups and scores your spoken answers; the first 90 seconds are free to try.
Introduction
React interview questions 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, React 19.2 added the Activity component and useEffectEvent, React 19.3 (September 2026) stabilised View Transitions, and the React Compiler (stable since October 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 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 is the frontend skill most job descriptions ask for, and product companies, service companies and global capability centres hiring from India test it 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.
This page holds 96 React interview questions and answers arranged from basic through advanced, each answered the way a strong candidate would answer in the room: a direct answer first, then the concrete APIs, real failure modes and trade-offs interviewers push on, with runnable code where it helps. The basic section (52 questions) is what freshers and campus rounds ask: what React is, JSX, props and state, the Virtual DOM, hooks, keys, events and forms, Context and Redux basics, React Router, a first Next.js and testing question, two coding tasks and four output questions. The intermediate section (32) is where two to four years of experience gets tested: memoisation and the compiler, state management today, data fetching, Suspense and transitions, testing, TypeScript, hydration, the Next.js App Router and three timed coding tasks. The advanced section (12) goes where senior offers are decided: Fiber, Server Components, the React Compiler, streaming SSR, useSyncExternalStore, security and production performance work.
The four-week preparation plan and the question map by experience level sit at the end of the basic section. Work through the questions in order, type out every code example rather than skimming it, then say the answers out loud: the AI mock interview linked at the top of this page asks React follow-ups and scores your spoken answers.
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.