React & Next.js — Interview Questions
Answered the way you would say it in the room — first person, with an example every time. Tap a question to reveal the answer. Levels: Basic, Intermediate, Advanced.
1. Why React Exists
BasicWhat is React, and why would you use it instead of plain JavaScript?
So React is a JavaScript library for building user interfaces out of components. The real reason I'd use it is that it lets me describe what the screen should look like for the current data, and React works out the DOM changes for me.
Let me give you an example. Think of a shopping cart. When someone adds an item, the header badge, the subtotal and the "Checkout" button all need to change. In plain JavaScript I'd write something like this:
cart.push(item);
badge.textContent = cart.length;
total.textContent = formatPrice(sum(cart));
checkoutBtn.disabled = cart.length === 0;
Every new feature adds another line, and the day someone forgets one, the screen lies to the user. In React I only update the data:
setCart(c => [...c, item]);
// and the JSX already says:
<span>{cart.length}</span>
<button disabled={cart.length === 0}>Checkout</button>
What happens here is that the badge, total and button are all derived from cart, so they can't drift out of sync. On top of that I get reusable components, a huge ecosystem, and a hiring pool that already knows it.
Interviewers look for: the "screen is derived from state" idea, not "because it has a virtual DOM".
BasicWhat does "declarative UI" mean? What is UI = f(state)?
So declarative means I describe the result I want, not the steps to get there. UI = f(state) is the short way of saying the screen is the output of a function whose input is my data. Same data in, same screen out.
Let me give you an example with a dropdown menu:
// imperative: I manage every transition myself
button.onclick = () => {
menu.classList.toggle("open");
button.setAttribute("aria-expanded", menu.classList.contains("open"));
};
// declarative: I describe each state
<button aria-expanded={isOpen} onClick={() => setIsOpen(o => !o)}>Menu</button>
{isOpen && <Menu />}
In the imperative version I have to reason about how to get from every possible state to every other state. In the declarative version I only think about what each state looks like. That's why bugs get easier to trace: if the screen is wrong, the data is wrong, and I can inspect the data.
So in practice, when I review React code and see someone reaching into the DOM to toggle a class, that's usually a sign the state is missing.
BasicIs React a library or a framework?
So React itself is a library: it handles rendering components and managing their state, and that's it. It doesn't give you routing, data fetching conventions, a build setup or a server.
Let me break it down:
| Concern | React alone | A framework like Next.js |
|---|---|---|
| Rendering components | Yes | Yes (built on React) |
| Routing | No, you add React Router or TanStack Router | File-based, built in |
| Server rendering, Server Components | The primitives only | Wired up end to end |
| Build tooling | No, you add Vite | Built in (Turbopack) |
The nuance I'd add is that the React team itself now recommends starting new apps with a framework, and Create React App was officially deprecated in early 2025. Features like Server Components and Server Actions only really work when a framework provides the bundler and server integration. So for a pure client-side SPA I'd use Vite plus a router, and for anything that needs SEO or server rendering I'd reach for Next.js or React Router's framework mode.
Interviewers look for: knowing that CRA is gone and why frameworks became the default.
IntermediateWhen would you not use React?
So I wouldn't use React when the page is mostly static content with very little interactivity. React has a cost: a runtime to download, parse and hydrate, and that cost only pays off when the UI actually changes a lot.
Let me give you some concrete cases:
- A marketing site or blog: I'd use something like Astro, which ships zero JavaScript by default and only hydrates the few interactive "islands".
- A small widget embedded on a third-party page: shipping all of React for one form is heavy, so plain JS, Preact or a web component might fit better.
- A server-rendered app with light interactivity: something like htmx on top of the backend's templates can be simpler for the team.
In my last project we had a docs site built as a React SPA. It scored poorly on Core Web Vitals, because every page waited for the whole bundle before showing text. Moving it to static generation fixed that without any React work at all.
So in practice I pick React when there's real client-side state: dashboards, editors, complex forms, anything app-like. And I'm happy to say "not React" when it isn't.
Interviewers look for: judgement. A candidate who says React is always the answer is a small red flag.
IntermediateHow does React compare with Vue, Angular, Svelte or Solid? Why would you choose it?
So the biggest technical difference is how they react to change. React re-runs your component function and diffs the output. Svelte, Solid, Vue and now Angular with signals track exactly which piece of data each part of the DOM depends on, so they can update that node directly without re-running whole components.
Let me put it side by side:
| Update model | Style | |
|---|---|---|
| React | Re-render and diff (Compiler auto-memoizes) | Just JavaScript and JSX |
| Vue | Reactive proxies, VDOM | Templates, SFCs |
| Angular | Signals, zoneless in newer versions | Full framework, DI, TypeScript-first |
| Svelte / Solid | Compiled, fine-grained, no VDOM | Minimal runtime |
The reason I'd still usually pick React is less about raw speed and more about everything around it: the ecosystem, Next.js, React Native for mobile, and how easy it is to hire. Fine-grained frameworks do win on micro-benchmarks, but in real apps the slow parts are usually network and bundle size, not the diff.
So in practice, if the team already knows React and we need a big ecosystem, React. If it's a small, performance-critical widget, I'd happily look at Solid or Svelte.
AdvancedWhat are the most important changes in React 19 and the current React ecosystem?
So the big theme of React 19 is that React now handles async work and the server natively, instead of leaving it all to libraries.
The things I'd call out:
- Actions: you can pass an async function to
<form action>, and React tracks pending state, errors and form resets. Alongside that comeuseActionState,useFormStatusanduseOptimistic. use: reads a promise or a context, and it can be called conditionally.- Server Components and Server Actions are stable, with frameworks like Next.js providing the plumbing.
- Quality of life:
refis a normal prop now, so mostforwardRefgoes away, and<Context>works directly as a provider. You can also render<title>and<meta>anywhere. - React Compiler reached 1.0 and memoizes components automatically, so a lot of manual
useMemoanduseCallbackbecomes unnecessary.
const [error, submit, isPending] = useActionState(async (prev, formData) => {
const res = await saveProfile(formData);
return res.ok ? null : res.message;
}, null);
<form action={submit}> … <button disabled={isPending}>Save</button></form>
In my last project that removed a whole layer of isLoading/error state we used to hand-roll for every form. 19.2 then added <Activity> and useEffectEvent.
Interviewers look for: that you're current, and that you can say what problem each feature solves, not just list names.
2. Components & JSX
BasicWhat is JSX, and what does it compile to?
So JSX is a syntax extension that lets me write HTML-looking markup inside JavaScript. The browser never sees it: a compiler like Babel, SWC or esbuild turns it into plain function calls that return plain objects.
Let me show you what actually happens:
// what I write
<div className="card">Hi {name}</div>
// what the modern JSX transform emits
import { jsx as _jsx } from "react/jsx-runtime";
_jsx("div", { className: "card", children: ["Hi ", name] });
// which returns a plain object, roughly
{ type: "div", props: { className: "card", children: ["Hi ", "Ada"] } }
Older setups compiled to React.createElement, which is why you used to need import React in every file. Since React 17's new JSX transform, the compiler imports the runtime for you.
Knowing it's just objects explains most of the JSX rules: you can only return one root because a function returns one value; you use className because class is a reserved word; and {} takes expressions, not statements.
Interviewers look for: "JSX is syntactic sugar for function calls that produce objects." Mentioning the react/jsx-runtime transform is a plus.
BasicWhy do component names have to start with a capital letter?
So the compiler uses the case to decide what to pass as the element type. Lowercase becomes a string, meaning a built-in HTML tag. Capitalised becomes a reference to a variable, meaning my component.
<button /> // _jsx("button", {}) a real DOM element
<Button /> // _jsx(Button, {}) calls my Button function
function button() { return <b>hi</b>; }
<button /> // still renders a native <button>, my function is ignored
What happens in that last case is a confusing bug: the code looks right, but React renders an empty native button because it never looks up my function. So it's not a style convention, it's how the compiler works.
The same rule applies to dynamic components. If I pick a component from a map, I assign it to a capitalised variable first:
const icons = { success: CheckIcon, error: AlertIcon };
const Icon = icons[status];
return <Icon />;
BasicWhy can't a component return two sibling elements? What's a Fragment?
So a component is a function, and a function returns one value. Two sibling JSX tags would be two values, which isn't valid JavaScript. A Fragment is an invisible wrapper that groups children without adding a real DOM node.
// won't compile
return <h1>Title</h1><p>Body</p>;
// works, and adds no extra <div> to the page
return (
<>
<h1>Title</h1>
<p>Body</p>
</>
);
The reason I prefer a Fragment over a wrapper div is that extra divs break things like CSS grid and flex layouts, and they make tables invalid: you can't put a div between a <tr> and its <td>s.
One gotcha: the short <> syntax can't take props. If I'm mapping and need a key on the group, I use the long form:
{terms.map(t => (
<Fragment key={t.id}>
<dt>{t.word}</dt>
<dd>{t.meaning}</dd>
</Fragment>
))}
IntermediateThis component sometimes shows a stray "0" on the page. Why?
{items.length && <ItemList items={items} />}
So the cause is how && works. It doesn't return true or false: it returns the left side when the left side is falsy. When the list is empty, the expression evaluates to the number 0.
React skips rendering false, null, undefined and true, but numbers are valid content. So it happily prints 0.
0 && "x" // 0 rendered as "0"
"" && "x" // "" renders nothing visible
null && "x" // null renders nothing
The fix is to make the left side a real boolean:
{items.length > 0 && <ItemList items={items} />}
{!!items.length && <ItemList items={items} />}
{items.length ? <ItemList items={items} /> : null}
In React Native it's worse: a bare 0 outside a <Text> crashes the app. So on teams I've worked with we just avoid numbers on the left of && entirely.
Interviewers look for: the explanation of why (&& returns the operand), not just the fix.
IntermediateWhat's the difference between a component, an element and an instance?
So a component is the function I write. An element is the plain object that JSX produces, describing what I want on screen. And an instance is the live thing React keeps in its tree for a mounted component, which is where its state and effects actually live.
function Avatar({ src }) { // component: a function
const [loaded, setLoaded] = useState(false);
return <img src={src} />;
}
const el = <Avatar src="/a.png" />; // element: { type: Avatar, props: {...} }
// cheap, immutable, can be created and thrown away freely
<>{el}{el}</> // two separate instances, two separate "loaded" states
What that last line shows is that an element is just a description. Rendering the same element object twice still gives two instances, each with its own state, because state belongs to the position in React's tree, not to the object.
With function components the "instance" isn't a this object like it was with classes. Internally it's a Fiber node, which holds the hooks list. That's why hooks have to be called in the same order every render: React matches them by position on that node.
IntermediateHow do you do conditional rendering? Can you use an if inside JSX?
So not directly. {} in JSX takes an expression, and if is a statement. But I have several options, and I pick based on how complex the condition is.
function OrderStatus({ order }) {
// 1. early return: great for loading / error / empty states
if (!order) return <Skeleton />;
if (order.error) return <ErrorBox error={order.error} />;
return (
<div>
{/* 2. ternary for either/or */}
{order.paid ? <Receipt /> : <PayButton />}
{/* 3. && for show/hide, with a real boolean */}
{order.items.length > 0 && <Items items={order.items} />}
</div>
);
}
For more than two branches I avoid nested ternaries. I'll either compute a variable above the return, or use a lookup object:
const panels = { shipping: ShippingPanel, billing: BillingPanel, review: ReviewPanel };
const Panel = panels[step];
return <Panel />;
Returning null is also valid when a component should render nothing. So in practice: early returns at the top, ternaries and && for small inline bits, and a map or variable for anything bigger.
AdvancedWhy must components be pure? What does React do to catch impure ones?
So a pure component always returns the same JSX for the same props, state and context, and doesn't change anything outside itself while rendering. React relies on that because it may call my function more than once, pause it, throw the result away, or render it ahead of time.
Let me give you an example of an impure one:
let guestCount = 0;
function Guest() {
guestCount++; // side effect during render
return <p>Guest #{guestCount}</p>;
}
This looks fine in a quick test, but in development with <StrictMode> React deliberately calls each component twice, so the numbers jump by two. That double call is there exactly to expose this kind of bug. In production, concurrent features like useTransition can start a render and discard it, and the counter would drift in the same way.
The rules I follow: no mutating props, state or outside variables during render; no fetching, subscribing or timers in the function body. Those go in event handlers or effects. Mutating a local variable I just created inside the render is fine.
It matters even more now with the React Compiler, which assumes purity when it decides what it can safely memoize.
Interviewers look for: linking StrictMode's double render to purity, and knowing where side effects belong instead.
3. Props — passing data down
BasicWhat are props, and why are they read-only?
So props are the inputs a parent passes to a child, like arguments to a function. They're read-only because the data belongs to the parent. If the child could change them, the parent's state and the screen would disagree, and nobody could tell who changed what.
function PriceTag({ amount, currency = "USD" }) {
// amount = amount * 1.2; don't: that's the parent's value
const withTax = amount * 1.2; // derive a new local value instead
return <span>{format(withTax, currency)}</span>;
}
<PriceTag amount={49} />
What this gives me is one-way data flow. When a value on screen is wrong, I walk up the tree, parent by parent, until I find who owns it. There's only ever one owner.
Technically, in development React freezes the props object, so assigning to props.amount throws in strict mode. But the real reason is design, not enforcement.
BasicWhat's the difference between props and state?
So props are passed in from the parent and the component can't change them. State is owned by the component itself, and it changes over time through its setter, which triggers a re-render.
| Props | State | |
|---|---|---|
| Who owns it | The parent | This component |
| Can the component change it? | No | Yes, via the setter |
| Triggers a re-render when it changes | Yes, when the parent re-renders | Yes |
| Analogy | Function arguments | The component's memory |
Let me give you an example. A SearchBox might keep the typed text in state, then pass it down as a prop:
function SearchPage() {
const [query, setQuery] = useState(""); // state here
return <Results query={query} />; // prop there
}
The key insight is that the same piece of data is state in one component and props in its children. So the question isn't really "props or state", it's "which component should own this state".
BasicHow does a child send data back up to its parent?
So the parent passes a function down as a prop, and the child calls it. The child reports what happened; the parent decides what to do about it.
function TodoList() {
const [todos, setTodos] = useState([]);
const toggle = id =>
setTodos(ts => ts.map(t => (t.id === id ? { ...t, done: !t.done } : t)));
return todos.map(t => <TodoItem key={t.id} todo={t} onToggle={toggle} />);
}
function TodoItem({ todo, onToggle }) {
return (
<label>
<input type="checkbox" checked={todo.done} onChange={() => onToggle(todo.id)} />
{todo.title}
</label>
);
}
What happens here is TodoItem has no idea how the list is stored. It just says "item 7 was toggled". That's what makes it reusable: I could drop it into a list backed by a server mutation and it wouldn't change.
I name these props onSomething for the prop and handleSomething for the function that implements it, which is the common convention.
IntermediateWhat is the children prop, and how does it help with composition?
So children is whatever I put between a component's opening and closing tags. It lets me build wrappers like cards, modals and layouts that don't care what goes inside them.
function Modal({ title, onClose, children }) {
return (
<div role="dialog" aria-label={title}>
<header>{title} <button onClick={onClose}>Close</button></header>
{children}
</div>
);
}
<Modal title="Delete file?" onClose={close}>
<p>This can't be undone.</p>
<DangerButton onClick={remove} />
</Modal>
The reason I like this over props like bodyText and showDangerButton is that the Modal never grows a new prop for every use case. React's docs push composition over inheritance for exactly this reason.
There's also a nice performance side effect. If a stateful wrapper receives children, those children were created by the parent. So when the wrapper's own state changes, the children elements are the same objects as before and React can skip re-rendering them. I've used that to fix a slow page: I moved the state into a small wrapper and passed the expensive tree in as children.
Interviewers look for: composition as the first answer to "how would you make this component flexible", before reaching for config props.
IntermediateWhat is prop drilling, and how do you deal with it?
So prop drilling is passing a prop through several components that don't use it, just so a deeply nested one can. It's not a bug, but it makes the middle components noisy and hard to refactor.
<Page user={user}> // doesn't use user
<Layout user={user}> // doesn't use user
<Header user={user}> // doesn't use user
<Avatar user={user} /> // the only one that does
My order of fixes is:
- Composition first. Often
Pagecan render<Layout header={<Header avatar={<Avatar user={user} />} />} />, so the middle layers just place slots and never seeuser. - Context for values that are truly global to a subtree and change rarely: current user, theme, locale.
- A store like Zustand or Redux Toolkit when lots of distant components read and write the same client state, since context re-renders every consumer on each change.
- A server-state library like TanStack Query if the data comes from an API anyway; then each component just calls
useUser().
So in practice, two or three levels of drilling is fine and explicit. I only reach for context when it's getting genuinely painful.
AdvancedA colleague copied a prop into state: useState(props.initialName). The field doesn't update when the parent changes. What's going on?
So the argument to useState is only used on the first render. After that, React keeps its own copy, and later changes to the prop are ignored. Now there are two sources of truth, and they drift.
function EditName({ user }) {
const [name, setName] = useState(user.name); // captured once
return <input value={name} onChange={e => setName(e.target.value)} />;
}
// switching from Ada to Linus in the parent: the input still says "Ada"
There are really three fixes, depending on what we actually want:
- If the child doesn't need to edit it, don't copy it: read
user.namedirectly, or derive from it during render. - If it's a draft that should reset when the user changes, give it a key:
<EditName key={user.id} user={user} />. A new key means a fresh instance with fresh state. - If it should be fully controlled, lift the state to the parent and pass
valueandonChange.
What I avoid is syncing with an effect, useEffect(() => setName(user.name), [user.name]). It renders once with the stale value, then again, and it overwrites whatever the user was typing. When I do want to copy a prop on purpose, I name it initialName or defaultName so it's clear later updates are ignored.
Interviewers look for: the key reset trick and a clear dislike of "sync state with useEffect".
4. State — data that changes
BasicWhy can't I just use a normal variable instead of useState?
So two things break. First, changing a local variable doesn't tell React anything, so it never re-renders. Second, even if it did, the function would run again from the top and reset the variable.
function Counter() {
let count = 0; // reset on every render
return <button onClick={() => count++}>{count}</button>; // always shows 0
}
function Counter() {
const [count, setCount] = useState(0); // stored by React
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
What useState does is keep the value outside my function, attached to this component's spot in the tree, and give me a setter that schedules a re-render.
The flip side is that not everything needs to be state. If I need to remember something across renders but changing it shouldn't update the screen, like a timer id, I use useRef. And if it can be calculated from props or other state, it should just be a const in the render.
BasicWhy does console.log right after setCount show the old value?
function handleClick() {
setCount(count + 1);
console.log(count); // still 0
}
So state in React is a snapshot. Inside one render, count is a plain constant, fixed at the value React gave that render. setCount doesn't change that constant; it asks React to render again later with a new value.
The way I explain it is: every render is its own function call with its own count. The event handler was created during the render where count was 0, so it sees 0 forever. The next render gets a brand-new count of 1 and new handlers that see 1.
If I need the new value right away in the same handler, I just compute it myself:
function handleClick() {
const next = count + 1;
setCount(next);
analytics.track("count_changed", { value: next });
}
If I need to react to the value after the screen updates, that's an effect. But most of the time, calculating next is the right answer.
Interviewers look for: the word "snapshot", and not saying "because setState is async", which is only half of it.
IntermediateWhat's the difference between setCount(count + 1) and setCount(c => c + 1)?
So the first one says "replace the state with this value", and the value was computed from the snapshot. The second one is an updater function: "take whatever the latest queued state is, and add 1".
Let me give you an example:
// count is 0
setCount(count + 1);
setCount(count + 1);
setCount(count + 1); // result: 1, all three read the same frozen 0
setCount(c => c + 1);
setCount(c => c + 1);
setCount(c => c + 1); // result: 3, each gets the previous result
What happens is that React queues the updates and processes them in order on the next render. Values replace; functions transform what's already there.
Where this bites in real code isn't usually three calls in a row. It's async code and effects, where the handler has an old snapshot:
useEffect(() => {
const id = setInterval(() => setSeconds(s => s + 1), 1000);
return () => clearInterval(id);
}, []);
With setSeconds(seconds + 1) there, it would be stuck at 1 forever, because the interval closed over the first render's seconds. So my rule is: whenever the next state depends on the previous one, use the updater.
IntermediateWhy shouldn't you mutate state directly? How do you update nested state?
So React decides whether something changed by comparing the old and new value with Object.is, which for objects means "is it the same object in memory". If I mutate and pass the same object back, React sees no change and may skip the re-render.
// broken: same array reference
items.push(newItem);
setItems(items);
// correct: a new array
setItems(prev => [...prev, newItem]);
Nested updates are where people slip, because you have to copy every level on the path to the change:
setUser(u => ({
...u,
address: { ...u.address, city: "Berlin" },
}));
If I only spread the top level and mutate address, the old and new user share the same address object. Anything memoized on user.address won't update, and an "undo" feature keeping the previous state would silently change too.
For deeply nested data I use Immer, via useImmer or Redux Toolkit, which lets me write mutation-style code and produces immutable copies. Or I flatten the state, for example storing items by id instead of nesting them three levels deep.
Also, structuredClone everywhere isn't the answer: it creates new references for everything, so every memoized child re-renders.
IntermediateHow do you decide where a piece of state should live?
So my default is: in the lowest component that needs it. When two siblings need the same data, I lift it to their closest common parent and pass it down. And if something can be calculated from existing state, it isn't state at all.
Let me give you an example from a product listing page:
function ProductsPage({ products }) {
const [query, setQuery] = useState(""); // both children need it
const visible = products.filter(p => // derived, not state
p.name.toLowerCase().includes(query.toLowerCase())
);
return (
<>
<SearchBox value={query} onChange={setQuery} />
<ProductGrid products={visible} />
<p>{visible.length} results</p>
</>
);
}
What I'm avoiding here is keeping filteredProducts and resultCount in state too. That's three copies of one fact, and one day they disagree.
A few other placements I think about: if the state should survive a refresh or be shareable, like filters or the current tab, it belongs in the URL search params. If it's server data, it goes in a cache like TanStack Query rather than component state. And a hover state stays right in the button, not up in the page.
Interviewers look for: "lift state up", "don't store derived data", and ideally mentioning URL state.
AdvancedHow do you choose between local state, context, a global store, a server cache and the URL?
So I first ask what kind of state it is, because different kinds have different owners. Most "state management" debates go away once you split server state from client state.
| Kind of state | Where I put it |
|---|---|
| UI state for one component (open, hovered, draft input) | useState / useReducer |
| Data from the backend | TanStack Query or SWR, or Server Components in Next.js |
| Shareable, bookmarkable (filters, page, tab) | URL search params |
| Rarely changing, app-wide (theme, user, locale) | Context |
| Frequently changing client state shared widely (editor, cart, canvas) | Zustand, Redux Toolkit, Jotai |
| Form state | Form actions, or React Hook Form for big forms |
In my last project we had a Redux store that was about 80% cached API responses with hand-written loading flags. Moving that to TanStack Query deleted most of the store, and what was left, a few UI preferences, fit in a small Zustand store.
The trap with context is using it for fast-changing values: every consumer re-renders on every change. So in practice I keep context for low-frequency values and use a store with selectors when many components subscribe to parts of a changing object.
Interviewers look for: the server-state versus client-state split. It shows real-world experience more than naming a library.
5. Events & Forms
BasicWhat's the difference between onClick={handleClick} and onClick={handleClick()}?
So the first passes the function to React to call later, when the click happens. The second calls it immediately, during render, and passes whatever it returns as the handler.
<button onClick={handleClick}>Save</button> // correct
<button onClick={handleClick()}>Save</button> // runs on every render
<button onClick={() => remove(item.id)}>Delete</button> // wrap to pass arguments
The second one is a nasty bug if the handler sets state. It runs during render, sets state, which triggers another render, which calls it again, and React bails out with "Too many re-renders".
When I need arguments, I wrap it in an arrow function. People sometimes worry that creates a new function every render. It does, but that's cheap, and it only matters if the child is memoized. With the React Compiler it's handled for you anyway.
BasicWhat's the difference between controlled and uncontrolled inputs? When would you use each?
So in a controlled input, React state holds the value and the input just displays it. In an uncontrolled input, the DOM keeps the value itself, and I read it when I need it, through a ref or FormData.
// controlled
const [email, setEmail] = useState("");
<input value={email} onChange={e => setEmail(e.target.value)} />
// uncontrolled
<form onSubmit={e => {
e.preventDefault();
const data = new FormData(e.currentTarget);
save(data.get("email"));
}}>
<input name="email" defaultValue="" />
</form>
I use controlled when the UI depends on the value while typing: live validation, formatting a phone number, disabling Submit until it's valid, or two fields that affect each other.
I use uncontrolled when I only need the value on submit. It's less code and doesn't re-render on every keystroke. With React 19's form actions, uncontrolled is actually the natural default again, because the action receives FormData directly. File inputs are always uncontrolled.
Interviewers look for: "single source of truth" for controlled, and a real reason to pick each, not "controlled is always better".
IntermediateYou see "A component is changing an uncontrolled input to be controlled." What causes it?
So React decides whether an input is controlled by whether value is undefined or not. If the first render passes undefined and a later one passes a string, the input switched modes halfway through, and React warns because it's ambiguous who owns the value.
Let me give you the typical case:
const [user, setUser] = useState({}); // name doesn't exist yet
<input value={user.name} onChange={…} />
// first render: value={undefined} -> uncontrolled
// after fetch: value="Ada" -> controlled -> warning
The fix is to always give a defined initial value:
const [user, setUser] = useState({ name: "" });
// or at the input
<input value={user.name ?? ""} onChange={…} />
The related warning is the opposite one, "You provided a value prop without an onChange handler". That means the input is locked: typing does nothing. Either add onChange, add readOnly if it's display only, or use defaultValue if I don't want to control it.
IntermediateHow does React's event system work? What's a SyntheticEvent?
So React doesn't attach a listener to every button. It uses event delegation: one listener per event type on the root container, and when a native event bubbles up, React works out which components it passed through and calls their handlers. The event object I receive is a SyntheticEvent, a cross-browser wrapper around the native event.
function Row({ onSelect }) {
return (
<li onClick={onSelect}>
Item
<button onClick={e => { e.stopPropagation(); remove(); }}>Delete</button>
</li>
);
}
What happens here is clicking Delete would also select the row, because the event bubbles through React's tree. stopPropagation stops that. If I need the raw event, it's on e.nativeEvent.
Two details that come up. Since React 17, listeners live on the root container, not on document, which made it safe to run multiple React versions or embed React in other apps. And events bubble through the React tree, not the DOM tree, so a click inside a portal, like a modal rendered into body, still bubbles to its React parent. That once surprised me when a modal click was closing a dropdown behind it.
Interviewers look for: delegation to the root, and the portal bubbling behaviour as a bonus.
IntermediateHow would you handle a large form with 30 fields and complex validation?
So I wouldn't write 30 useState calls. At that size I use a form library, usually React Hook Form with a Zod schema, because it keeps inputs mostly uncontrolled and only re-renders the fields that change.
const schema = z.object({
email: z.string().email(),
age: z.coerce.number().min(18),
});
function SignupForm() {
const { register, handleSubmit, formState: { errors } } =
useForm({ resolver: zodResolver(schema) });
return (
<form onSubmit={handleSubmit(save)}>
<input {...register("email")} />
{errors.email && <p role="alert">{errors.email.message}</p>}
<input {...register("age")} />
<button>Sign up</button>
</form>
);
}
The reason is performance and structure. With fully controlled state in one big object, every keystroke re-renders the whole form. In my last project a settings page with about 40 fields felt laggy on low-end laptops for exactly that reason.
The other win is sharing the Zod schema with the server. In Next.js I validate the same schema again inside the Server Action, because client validation is only for user experience; the server check is the one that actually protects the data.
For small forms, a single object in useState with one generic onChange, or a plain form action, is enough.
AdvancedHow do form Actions work in React 19? What do useActionState and useFormStatus give you?
So in React 19 a form's action prop can be a function, including an async one. React calls it with the FormData, runs it inside a transition so the UI stays responsive, and resets uncontrolled fields when it succeeds.
function NewComment({ postId }) {
const [error, submitAction, isPending] = useActionState(
async (prevError, formData) => {
const res = await addComment(postId, formData.get("text"));
return res.ok ? null : "Could not post comment";
},
null
);
return (
<form action={submitAction}>
<textarea name="text" required />
<SubmitButton />
{error && <p role="alert">{error}</p>}
</form>
);
}
function SubmitButton() {
const { pending } = useFormStatus(); // reads the parent form's status
return <button disabled={pending}>{pending ? "Posting…" : "Post"}</button>;
}
What happens here: useActionState wraps my function, gives me its latest return value and an isPending flag. useFormStatus has to be called in a component inside the form; a gotcha I've hit is calling it in the same component that renders the <form>, where it always says not pending.
In Next.js the action can be a Server Action, which means the form even works before JavaScript loads. I'd add useOptimistic if I want the comment to appear instantly.
Interviewers look for: pending state, error handling via the return value, and progressive enhancement with Server Actions.
6. Lists & Keys
BasicWhy does React need a key when rendering a list?
So a key is how React recognises the same item between renders. Without one it can only match items by position; with one it can say "the item with id 7 moved to the top" and keep that item's DOM and state attached to it.
{users.map(user => (
<UserRow key={user.id} user={user} />
))}
Let me give you what goes wrong without it. Say each row has a local "expanded" state, and a new user is added to the top. Matching by position, React thinks row 0 is still the old row 0, so the expanded state stays at index 0 and now belongs to a different user. With keys, React sees a new key, inserts one row, and moves the others without touching their state.
A few rules I follow: keys must be stable, meaning the same for that item every render; unique among siblings, not the whole page; and they go on the outermost element returned from map. A database id is ideal.
Interviewers look for: "identity across renders", not "React needs it for performance".
IntermediateWhy is using the array index as a key a problem? Show me a bug it causes.
So an index isn't tied to the item, it's tied to the position. When items are inserted, removed or reordered, the same index points at a different item, and any state React kept for that key goes to the wrong item.
Let me give you a concrete bug:
function TodoList() {
const [todos, setTodos] = useState([
{ id: "a", text: "Buy milk" },
{ id: "b", text: "Call Sam" },
]);
return todos.map((t, i) => (
<div key={i}>
{t.text} <input placeholder="note" />
<button onClick={() => setTodos(ts => ts.filter(x => x.id !== t.id))}>x</button>
</div>
));
}
What happens: I type "2%" into the note next to "Buy milk", then delete "Buy milk". Now "Call Sam" is at index 0, so it gets key 0, and React reuses the old row 0's DOM, including the uncontrolled input with "2%". The note has jumped to the wrong todo.
With key={t.id}, React sees key "a" disappear and removes exactly that row.
The same problem shows up with checkboxes, animations, and components with their own state. And it's worse than the "performance" framing suggests: it's a correctness bug that only appears after the user reorders or deletes something.
IntermediateIs it ever fine to use the index as a key? What if the data has no id?
So yes, index is fine when all three are true: the list is static or only appended to at the end, it's never reordered or filtered, and the rows have no state of their own. A list of breadcrumbs or a fixed nav menu is a good example. React even falls back to index if you give no key, it just warns.
When the data has no id, I create one at the moment the data enters the app, not during render:
// good: id created once, when the item is added
function addItem(text) {
setItems(prev => [...prev, { id: crypto.randomUUID(), text }]);
}
// bad: a new key every render
{items.map(item => <Row key={Math.random()} item={item} />)}
The Math.random() version is the worst of all worlds: every render, every key is new, so React unmounts and remounts every row. Inputs lose focus, state resets, and it's slow.
If the data comes from an API without ids, I look for a natural unique field, like a slug, email or a combination such as `${date}-${title}`, as long as I'm sure it's unique among siblings.
IntermediateHow can you use a key outside of lists?
So a key is really an identity for any element, not just list items. If I change a component's key, React treats it as a different component: it unmounts the old one and mounts a fresh one with brand-new state. That makes it a clean way to reset a component.
Let me give you an example from a chat app I worked on:
function ChatPage({ contactId }) {
return <ChatWindow key={contactId} contactId={contactId} />;
}
Without the key, switching from Alice to Bob kept the same ChatWindow instance, so the unsent draft message and scroll position carried over to Bob's chat. That's a privacy bug as well as a UX bug. The first attempt at a fix was a useEffect that cleared every piece of state when contactId changed, and we kept missing fields. Adding the key fixed all of it in one line.
The trade-off is that remounting throws away everything, including the DOM and any child state, and it re-runs effects. So I use it when "start completely fresh" is genuinely the intended behaviour, like switching the record being edited.
Interviewers look for: knowing this pattern at all. Many candidates only know keys as a list requirement.
IntermediateCan a child component read its own key? What happens with duplicate keys?
So no. key is consumed by React for reconciliation and isn't passed through as a prop. If I read props.key I get undefined, and React warns in development. If the child needs the id, I pass it separately:
{products.map(p => (
<ProductCard key={p.id} id={p.id} product={p} />
))}
Duplicate keys are a real bug, not just a warning. React assumes each key is one item, so with two siblings sharing a key it can end up duplicating or dropping elements when the list updates. In my experience it usually shows up as a row that refuses to disappear after deletion, or an item rendered twice after a refetch.
The common causes I've seen are using a field that's only mostly unique, like a name, or merging two pages of paginated results that overlap. So when I see the "Encountered two children with the same key" warning, I treat it like an error and fix the data, usually by de-duplicating by id before rendering.
AdvancedHow would you render a list of 10,000 rows without the page freezing?
So the main answer is virtualization: only render the rows that are actually visible, plus a small buffer, and fake the total height so the scrollbar still behaves. Ten thousand DOM rows is slow to create, slow to lay out and heavy on memory, no matter how efficient React's diff is.
import { useVirtualizer } from "@tanstack/react-virtual";
function BigList({ rows }) {
const parentRef = useRef(null);
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 40,
overscan: 8,
});
return (
<div ref={parentRef} className="scroller">
<div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
{virtualizer.getVirtualItems().map(v => (
<Row key={rows[v.index].id} row={rows[v.index]} top={v.start} />
))}
</div>
</div>
);
}
What happens is the DOM only ever holds around 30 rows, whatever the list size. In my last project an audit log table went from a multi-second freeze to instant with this change.
A few other things I'd combine it with: pagination or infinite loading so we don't even fetch 10,000 rows; React.memo on rows if they re-render a lot; useDeferredValue for a filter box over the list; and CSS content-visibility: auto as a cheap option for long, non-interactive content.
The trade-offs of virtualization are that Ctrl+F in the browser can't find off-screen rows, and variable row heights need measuring. So I only virtualize when the list is really big.
Interviewers look for: virtualization first, and awareness of its trade-offs such as search, accessibility and dynamic heights.
7. The Render Cycle
BasicWhat does "render" actually mean in React? What's the difference between render and commit?
So in React, "render" doesn't mean drawing pixels. It means React calling my component function to find out what the UI should look like. Commit is the separate step where React applies the differences to the real DOM.
There are really three phases:
- Trigger: a state update, a parent re-render, or a context change.
- Render: React calls the components, gets the new element tree and compares it with the previous one. Nothing on screen changes yet, and this work can be paused or discarded.
- Commit: React makes the DOM changes, runs
useLayoutEffect, the browser paints, and thenuseEffectruns.
function Clock({ time }) {
console.log("render"); // runs every time Clock is called
useEffect(() => {
console.log("committed"); // runs after the DOM is updated
});
return <h1>{time}</h1>;
}
The practical takeaway is that a render which produces the same output causes no DOM work at all. So "my component rendered 5 times" is not automatically a problem; the expensive part is usually the commit and browser layout, or genuinely heavy work inside the render function.
Interviewers look for: render is calling functions and diffing, commit is touching the DOM. This question is often a lead-in to performance.
BasicWhat causes a component to re-render?
So there are three triggers: its own state changed, its parent re-rendered, or a context it reads changed. That's it. Notably, "its props changed" isn't really a separate trigger; props only change because the parent re-rendered.
function App() {
const [count, setCount] = useState(0);
return (
<>
<button onClick={() => setCount(c => c + 1)}>{count}</button>
<Sidebar /> {/* no props, still re-renders on every click */}
</>
);
}
The one that surprises people is the second. Sidebar takes no props, yet it re-renders every time App does, because by default React re-renders the whole subtree below the component that updated. It doesn't check props first, because that comparison is itself work, and re-running a cheap function is usually cheaper.
A common misconception is that changing a ref or a plain variable causes a re-render. It doesn't; only state setters, a parent render, or context do. That's also why mutating a ref is a good place for values the screen doesn't depend on.
IntermediateHow do you stop a child from re-rendering when its props haven't changed?
So the direct tool is React.memo, which makes React shallow-compare the props and skip the render if they're all the same. But there are often simpler structural fixes that I'd try first.
const Chart = memo(function Chart({ data, onPointClick }) { … });
function Dashboard() {
const [filter, setFilter] = useState("");
const data = useMemo(() => buildSeries(rawData), [rawData]);
const onPointClick = useCallback(p => openDetail(p.id), []);
return <Chart data={data} onPointClick={onPointClick} />;
}
What happens here is that memo only works if the props are referentially stable. If I pass an inline object or arrow function, it's new every render, and memo compares, finds a difference and renders anyway, so I've paid for the comparison for nothing. That's why memo usually comes together with useMemo and useCallback.
The structural fixes are often better: move the state down into the smaller component that actually uses it, or lift the expensive tree up and pass it in as children, so it isn't recreated when the wrapper's state changes.
In 2026 I'd also mention the React Compiler: it does this memoization automatically at build time, so on a project with the compiler enabled, most hand-written memo, useMemo and useCallback is unnecessary.
Interviewers look for: knowing memo is useless without stable props, and mentioning "move state down" before reaching for memoization.
IntermediateWhy does my component render twice, and my effect run twice, in development?
So that's <StrictMode>, and it's intentional. In development only, React calls component functions twice and, on mount, runs every effect's setup, then its cleanup, then the setup again. It doesn't happen in production.
useEffect(() => {
const conn = createConnection(roomId);
conn.connect();
return () => conn.disconnect(); // with this, the double run is harmless
}, [roomId]);
The reason is to surface bugs early. Rendering twice catches impure components, like ones that mutate outside variables. Mounting, unmounting and remounting catches effects without proper cleanup: a subscription that leaks, a listener added twice, or an interval that never clears. It simulates what happens with things like Fast Refresh, or <Activity> hiding and showing a subtree while preserving its state.
In my last project a chat widget opened two WebSocket connections in dev. Everyone blamed StrictMode, but the real bug was a missing cleanup, and in production it showed up as duplicate messages after navigating away and back.
So in practice I never "fix" it by removing StrictMode or adding a useRef flag to skip the second run. I fix the cleanup. For one-off data fetching, I use a library or a framework loader, which handle this properly.
IntermediateIn what order do things run: render, DOM update, useLayoutEffect, paint, useEffect?
So the order is: render (my function body), then commit, where React updates the DOM, then useLayoutEffect runs synchronously before the browser paints, then the browser paints, and useEffect runs after that.
function Tooltip({ anchorRect }) {
const ref = useRef(null);
const [top, setTop] = useState(0);
useLayoutEffect(() => {
const { height } = ref.current.getBoundingClientRect();
setTop(anchorRect.top - height); // re-renders before the user sees anything
}, [anchorRect]);
return <div ref={ref} style={{ top }}>…</div>;
}
What happens here is the tooltip renders once at top: 0, I measure it in the layout effect, set the real position, and React re-renders synchronously, all before paint. The user never sees it jump. With useEffect there would be a visible one-frame flicker at the wrong position.
The trade-off is that useLayoutEffect blocks painting, so I only use it for measuring and positioning. Everything else, like fetching, subscriptions and analytics, goes in useEffect. One nuance: if the update came from a discrete user event like a click, React may flush useEffect before paint too, so I never rely on the "after paint" timing for correctness.
AdvancedA page feels slow. How do you find out which components are re-rendering and why?
So I measure before I change anything. The React DevTools Profiler is my main tool: I record the slow interaction, and it shows every commit, how long each component took, and, with the "record why each component rendered" setting on, the reason, like "props changed: onSelect" or "parent rendered".
My process usually looks like this:
- Turn on "Highlight updates when components render" and do the interaction, to see visually what's flashing that shouldn't be.
- Record in the Profiler and look at the flame chart for the commit that took the longest.
- Check the Performance panel in Chrome as well, because sometimes the slow part isn't React at all, it's layout thrashing or a huge third-party script.
Let me give you a real case. In my last project, typing in a search box on a dashboard took about 200ms per keystroke. The Profiler showed the whole page re-rendering because the search state lived in the page component, and a heavy chart grid re-rendered with it. The fix was moving the query state into a small SearchBar component and passing the filtered data down with useDeferredValue, so the grid updated at lower priority.
const deferredQuery = useDeferredValue(query);
const rows = useMemo(() => filterRows(all, deferredQuery), [all, deferredQuery]);
I'd also use the <Profiler> component or web-vitals INP data in production, because dev builds are slower and can mislead.
Interviewers look for: a measure-first process and specific tools, rather than "I'd add useMemo everywhere".
8. The Virtual DOM & Diffing
BasicWhat is the virtual DOM?
So the virtual DOM is just a tree of plain JavaScript objects describing what the UI should look like. Every time a component renders, React produces a new tree, compares it with the previous one, and applies only the differences to the real DOM.
// <ul><li>Milk</li><li>Eggs</li></ul> becomes roughly
{
type: "ul",
props: {
children: [
{ type: "li", props: { children: "Milk" } },
{ type: "li", props: { children: "Eggs" } },
],
},
}
The reason it exists is that real DOM nodes are heavy objects with hundreds of properties, and changing them can force the browser to recalculate layout. Plain objects are cheap to create and compare. So if I add "Bread" to that list, React compares the two small trees, sees one new li, and does a single appendChild.
The "virtual DOM" name is a bit old-fashioned now. The React team talks more about elements and the Fiber tree, but interviewers still use the term, and the idea is the same.
IntermediateIs the virtual DOM faster than the real DOM?
So honestly, no, not in itself. The virtual DOM is extra work on top of the DOM updates. A carefully hand-written update, like setting one text node directly, will always beat building a tree, diffing it and then setting that same text node.
What the virtual DOM gives you is good enough performance without writing the update code yourself. Let me give you an example of the comparison that matters:
// naive approach many apps used before: re-create everything
list.innerHTML = items.map(i => `<li>${i.name}</li>`).join("");
// destroys focus, selection, scroll position and every node
// React: describe the full list, but only changed nodes are touched
<ul>{items.map(i => <li key={i.id}>{i.name}</li>)}</ul>
Compared with "re-render everything", the diff is a big win. Compared with ideal hand-tuned updates, it's slower. The real value is that I get to write code as if I re-render everything, which is simple and bug-resistant, and pay close to the cost of minimal updates.
That's also why Svelte and Solid can beat it: they compile to direct DOM updates and skip the diff. So I'd frame the virtual DOM as a developer-experience trade-off, not a speed trick.
Interviewers look for: the honest answer. "The virtual DOM makes React fast" is a common myth they're testing for.
IntermediateHow does React's diffing algorithm stay fast?
So a fully general tree diff is roughly O(n³), which would be far too slow for UI. React gets to O(n) by making a few assumptions that are almost always true in real apps.
There are really two heuristics:
- Different type means a different tree. If a
divbecomes asection, or<Login>becomes<Dashboard>, React doesn't try to match anything inside. It unmounts the old subtree and mounts the new one. - Children are matched by key, or by position. React compares siblings at the same level and uses keys to track items that moved, rather than searching the whole tree for a match.
// same type: React keeps the node and patches one attribute
<button className="primary">Save</button>
<button className="primary busy">Save</button>
// different type: whole subtree rebuilt, state inside is lost
{isEditing ? <form><Editor /></form> : <div><Editor /></div>}
The consequence is that React never notices a component moving to a different parent or level. If I move a subtree from one parent to another, it's destroyed and recreated. So in practice, keeping the tree structure stable and using good keys is what keeps diffing both fast and correct.
IntermediateWhat is React Fiber?
So Fiber is the reconciler React has used since version 16. Each component instance is represented by a "fiber" node, a small object holding its type, props, state, hooks list and links to its parent, child and sibling. Rendering walks that tree one fiber at a time.
The key thing it made possible is interruptible rendering. The old stack reconciler was recursive, so once a big update started, it ran to the end and blocked the main thread. With Fiber, React can do some units of work, check whether something more urgent arrived, like a keystroke, and pause or throw away the in-progress work.
const [isPending, startTransition] = useTransition();
function onTabClick(tab) {
startTransition(() => setTab(tab)); // low priority, can be interrupted
}
That's what powers the concurrent features: transitions, useDeferredValue, Suspense, and selective hydration. Fiber also keeps two trees, the "current" one on screen and a "work-in-progress" one, and swaps them at commit, which is why a half-finished render never shows up on screen.
Interviewers look for: "units of work that can be paused", linked to concurrent features. Nobody expects the internal field names.
AdvancedWhat is concurrent rendering? When would you use useTransition or useDeferredValue?
So concurrent rendering means React can prepare more than one version of the UI and prioritise between them. Urgent updates, like typing, go first; non-urgent ones, like re-rendering a big filtered list, can be interrupted and restarted with newer data.
Let me give you an example: a search box over a large product grid.
function Search({ products }) {
const [query, setQuery] = useState("");
const deferredQuery = useDeferredValue(query);
const stale = query !== deferredQuery;
return (
<>
<input value={query} onChange={e => setQuery(e.target.value)} />
<div className={stale ? "dimmed" : ""}>
<ProductGrid products={products} query={deferredQuery} />
</div>
</>
);
}
What happens is the input re-renders with the new text immediately, and the grid renders with the old query until React has spare time. If I keep typing, the in-progress grid render is thrown away. For this to help, ProductGrid needs to be memoized, otherwise it re-renders on the urgent pass anyway.
The way I choose: useTransition when I own the state update, like switching tabs or navigating, and I want an isPending flag. useDeferredValue when I only receive the value, for example as a prop. The trade-off versus debouncing is that there's no fixed delay: on a fast machine it updates almost instantly. But neither makes the render itself faster, it just stops it blocking input.
Interviewers look for: the urgent versus non-urgent split, and knowing it's not a replacement for making slow components faster.
AdvancedHow do signals-based frameworks avoid the virtual DOM? How does the React Compiler change the picture?
So frameworks like Solid, Svelte 5 and Angular with signals track dependencies at a fine-grained level. When a signal changes, they know exactly which DOM node or computation reads it, so they update that one node. There's no component re-run and no tree diff.
// Solid: the component function runs once
function Counter() {
const [count, setCount] = createSignal(0);
return <button onClick={() => setCount(count() + 1)}>{count()}</button>;
// only the text node is updated on click
}
// React: the whole function re-runs, then the output is diffed
function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
React deliberately kept the "re-run the function" model because it's simple to reason about: every render is a pure snapshot. The cost is wasted work when big trees re-render.
The React Compiler is React's answer to that cost without changing the model. At build time it analyses components and memoizes JSX and values automatically, so unchanged parts of the tree get skipped. It's not fine-grained reactivity, React still diffs, but in practice it closes much of the gap and removes most manual useMemo and useCallback.
So my honest take: signals win on raw update performance; React wins on ecosystem and a simpler mental model, and the compiler narrows the difference.
9. Batching & How Updates Queue
BasicWhat is batching in React?
So batching means React groups several state updates that happen together into a single re-render, instead of re-rendering after each one.
function handleCheckout() {
setLoading(true);
setError(null);
setStep("payment");
// one re-render, not three
}
There are two benefits. It's less work, obviously. But more importantly, the user never sees a half-updated screen, like the step changed but the loading spinner hasn't appeared yet. All three changes land in the same commit.
The way I think about it is a waiter taking an order: they write down everything you ask for, then make one trip to the kitchen. React waits until the event handler, or the current task, has finished before rendering.
IntermediateWhat changed about batching in React 18?
So before React 18, batching only happened inside React event handlers. Updates inside setTimeout, promises or native event listeners each caused their own render. React 18 introduced automatic batching: with createRoot, updates are batched everywhere, whatever the source.
async function loadProfile() {
const data = await fetch("/api/me").then(r => r.json());
setUser(data.user);
setPermissions(data.permissions);
// React 17: two renders, and one of them had user without permissions
// React 18+: one render
}
What that fixed in practice: in React 17 I've seen components briefly render with the new user but old permissions, which caused a flash of "Access denied" before the second render. People used to work around it with unstable_batchedUpdates or by merging the state into one object.
The one condition is that you're on the new root API. Legacy ReactDOM.render kept the old behaviour, and it was removed entirely in React 19, so on any current app batching is automatic.
Interviewers look for: "batched everywhere since 18, including timeouts and promises".
IntermediateWhat is number after this click handler runs?
const [number, setNumber] = useState(0);
function handleClick() {
setNumber(number + 5);
setNumber(n => n + 1);
setNumber(42);
}
So the answer is 42. And without the last line, it would be 6.
Let me walk through the queue. React processes updates in order, starting from the current state, 0:
| Queued update | n going in | Result |
|---|---|---|
number + 5, a plain value 5 | 0 | 5 (replace) |
n => n + 1 | 5 | 6 (transform) |
42 | 6 | 42 (replace) |
The key thing is that a plain value is evaluated right away from the snapshot, so number + 5 is just the number 5 by the time it enters the queue. An updater function is stored and called later with whatever the previous step produced.
So in real code, if I mix both styles in one handler, the result depends on order, which is confusing. I stick to updater functions whenever an update depends on the previous value.
IntermediateIf you call setState with the same value, does the component re-render?
So usually not. React compares the new value with the current one using Object.is, and if they're equal it bails out without re-rendering the component's children. There's a small nuance: React may still call the component function once before bailing out, but it won't commit anything or go deeper.
setIsOpen(true); // already true: bail out
setFilters({ ...filters }); // new object with the same contents: re-renders
setCount(NaN); // Object.is(NaN, NaN) is true: bail out
The second line is the one that matters in practice. For objects and arrays it's the reference that's compared, not the contents. So a pattern like spreading an object "to be safe" before setting it always triggers a render, and it can cause an infinite loop if it happens inside an effect that depends on that object.
In my last project we had exactly that: an effect set filters to a fresh object parsed from the URL on every run, and filters was in its dependency array. The fix was to only set the state when the values actually differed, or better, derive the filters from the URL directly instead of copying them into state.
AdvancedWhen and why would you use flushSync?
So flushSync forces React to process the updates inside it and commit them to the DOM synchronously, before the next line of my code runs. It's an escape hatch for the rare case where I need the DOM to reflect new state immediately, usually to measure it or to move focus or scroll.
Let me give you the classic example: a chat where I add a message and want to scroll to it.
import { flushSync } from "react-dom";
function send(text) {
flushSync(() => {
setMessages(m => [...m, { id: crypto.randomUUID(), text }]);
});
// the new message is in the DOM now
listRef.current.lastElementChild.scrollIntoView({ behavior: "smooth" });
}
Without flushSync, the state update is batched and hasn't rendered yet, so lastElementChild is the previous message and I scroll to the wrong place.
The trade-offs: it opts out of batching, it can't be interrupted, so it hurts concurrent features, and calling it inside render or an effect gives a warning. Often there's a cleaner option, like doing the scroll in a useLayoutEffect that runs when messages changes, or using a ref callback on the new item. So I use flushSync sparingly, mainly when integrating with non-React code that expects the DOM to be ready immediately, like view transitions or a third-party library.
Interviewers look for: a concrete use case, plus knowing it's an escape hatch with a cost.
10. Reconciliation — the matching rules
BasicWhat is reconciliation?
So reconciliation is the process React uses to match the new element tree against the previous one and decide, element by element, whether it's the same thing as before, in which case it keeps it and updates it, or something new, in which case it unmounts the old one and mounts the new one.
The rules boil down to this: same type in the same position means update, and state is kept; different type means replace, and state is lost; and in lists, keys decide what "same" means.
// before // after // what React does
<Counter /> <Counter /> keep, state preserved
<div>…</div> <section>…</section> destroy and rebuild subtree
<Counter key="a"/> <Counter key="b"/> different identity: fresh state
The most useful thing to take from it is that state isn't stored "in" a component. It's stored at a position in the tree. Once you see it that way, a lot of confusing bugs, like state not resetting or state resetting unexpectedly, become easy to explain.
Interviewers look for: "state is tied to position and type in the tree".
IntermediateAn input loses focus after every keystroke. What's the most likely cause?
So the most likely cause is a component defined inside another component. Every render creates a brand-new function, React sees a different component type in that position, and it unmounts the old input and mounts a new one. The new input doesn't have focus.
function SignupForm() {
const [email, setEmail] = useState("");
// new "EmailField" type on every render
function EmailField() {
return <input value={email} onChange={e => setEmail(e.target.value)} />;
}
return <EmailField />;
}
What happens: I type one letter, setEmail re-renders SignupForm, a new EmailField function is created, and React compares it to the old one by reference. They're different functions, so the whole subtree is thrown away.
The fix is to move the component to the top level of the module and pass what it needs as props. If I just wanted a helper for readability, I can call it as a plain function, {renderEmailField()}, rather than using it as a component.
The other common cause of the same symptom is a key that changes every render, like key={Math.random()} or a key built from the input's own value.
IntermediateTwo different users' profiles share the same state when you switch between them. Why?
{isAlice
? <ProfileForm user={alice} />
: <ProfileForm user={bob} />}
So React sees the same component type in the same position of the tree in both branches. It doesn't care that the JSX is written in two places in my code; it only compares type and position. So it treats this as one ProfileForm getting new props, and keeps its state, like a half-typed bio.
There are two ways to fix it, and I choose based on intent. If they should be separate, I give them different keys:
{isAlice
? <ProfileForm key="alice" user={alice} />
: <ProfileForm key="bob" user={bob} />}
// or more generally
<ProfileForm key={user.id} user={user} />
Now switching resets the form, because the identity changed. Or, if I want each user's draft preserved while switching, I render both and hide one, or lift the draft state up and store it per user id.
This is the mirror image of the focus bug: there, React thought something was new when it wasn't; here, React thinks something is the same when it isn't.
Interviewers look for: "same type at the same position keeps state", and key as the fix.
IntermediateWhat happens when an element's type changes, for example a wrapper div becomes a section?
So React throws away the entire subtree under it and builds it again. Every component inside is unmounted, which runs their effect cleanups and loses their state, and then they're mounted fresh, with DOM nodes recreated and effects run again.
Let me give you an example I've seen in a real codebase:
function Card({ href, children }) {
const Wrapper = href ? "a" : "div";
return <Wrapper href={href} className="card">{children}</Wrapper>;
}
This looks harmless, but when href arrived after a fetch, the wrapper switched from div to a, and a video player inside the card restarted from the beginning, because it had been unmounted and remounted.
The fixes are to keep the outer structure stable and change attributes instead, or to decide the wrapper type once the data is ready rather than switching mid-life.
React makes this trade-off on purpose. A changed type almost always means genuinely different content, and not trying to match anything inside is what keeps the diff O(n).
AdvancedDoes <Chat /> lose its state when showBanner toggles?
return (
<div>
{showBanner && <Banner />}
<Chat />
</div>
);
So no, Chat keeps its state. The children of that div are always a two-item list: slot 0 is either <Banner /> or false, and slot 1 is always Chat. React renders false as nothing, but it still occupies the slot, so Chat never changes position.
Compare that with an early return that changes the structure:
if (showBanner) {
return <div><Banner /><Chat /></div>; // Chat at index 1
}
return <div><Chat /></div>; // Chat at index 0: remounted
Here, toggling the banner moves Chat from index 1 to index 0. Same type, different position, so React unmounts it and mounts a new one, and the chat loses its scroll position and draft.
I've seen this exact bug from a refactor that "cleaned up" inline conditionals into early returns. So when I restructure JSX, I think about whether stateful children keep the same position, and if in doubt I give them a stable key, since keyed children are matched by key rather than index.
Interviewers look for: knowing that false and null hold a slot, which shows real understanding of how children are matched.
AdvancedHow does React reconcile a list when items are reordered, with and without keys?
So without keys, React compares children pair by pair by index. If I move the last item to the top, every position now holds different data, so React updates the content of every row, and any state or DOM, like focus or an uncontrolled input, stays at the old index.
With keys, React first builds a map of the old children by key. Then, walking the new list, it finds each key's old fiber, reuses it, and only records which ones need moving, inserting or deleting.
// before: [A, B, C, D] after: [D, A, B, C]
// by index: 4 updates, each row's content rewritten,
// row state stays put and now belongs to the wrong item
// by key: A, B, C and D all keep their DOM and state;
// React moves nodes so the DOM order matches, no remounts
A detail I find interesting: React's list algorithm is optimised for the common cases, appending and removing, not for arbitrary moves. It walks forward and tracks the last position it placed, so moving one item from the end to the front can mean moving the other nodes rather than just the one. It's still correct, and for typical lists the cost doesn't matter.
So in practice, stable keys give correctness first, preserving state and DOM identity, and efficiency second. For drag-and-drop lists, that identity is exactly what keeps animations and focus working.
11. useState, Deeply
BasicWhat does useState actually give you, and where does the value live between renders?
So useState gives me a pair: the current value for this render, and a setter that asks React to re-render with a new value. The important bit is that the value does not live in my function. My function runs, returns JSX and its local variables are gone. React keeps the state in its own internal record for that component instance and hands it back on the next call.
Let me give you an example.
function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
function Page() {
return (
<>
<Counter /> {/* counts on its own */}
<Counter /> {/* separate instance, separate state */}
</>
);
}
What happens here is that each <Counter /> is its own instance in the tree, so each one gets its own slot. Clicking the first does nothing to the second. The 0 is only used on the very first render. After that React ignores it and returns whatever it has stored.
That's also why a plain let count = 0 inside the component doesn't work: it's recreated on every render, and changing it doesn't tell React anything.
Interviewers look for: state is per component instance and stored by React, and the initial value is only read on mount.
BasicWhy does calling setCount(count + 1) three times in a row only add one?
So it's because count is a snapshot. Inside one render it's just a constant. All three calls read the same value, so they all say "set it to 1".
// count is 0 in this render
function handleClick() {
setCount(count + 1); // set to 1
setCount(count + 1); // set to 1
setCount(count + 1); // set to 1
} // next render: 1
function handleClick() {
setCount(c => c + 1); // 0 -> 1
setCount(c => c + 1); // 1 -> 2
setCount(c => c + 1); // 2 -> 3
} // next render: 3
The fix is the updater function. Instead of handing React a value, I hand it a function, and React queues those and runs them in order, each one getting the result of the previous one.
My rule is simple: if the new state depends on the old state, I use the updater form. It also saves me from stale closures in timers and async callbacks, where the count I captured might be several renders old.
Interviewers look for: "each render is a snapshot" and the updater form. Bonus for mentioning it matters in setInterval and after an await.
IntermediateI push an item into an array in state and call the setter, but nothing re-renders. Why?
So React decides whether to re-render by comparing the old and new state with Object.is. If I mutate the array and pass the same array back, it's the same reference, so React sees "no change" and bails out.
Let me show you the bug and the fix.
// Bad: mutates, same reference
function addTodo(todo) {
todos.push(todo);
setTodos(todos); // Object.is(old, new) === true, skipped
}
// Good: new array
function addTodo(todo) {
setTodos(prev => [...prev, todo]);
}
// Good: nested object: copy every level you change
setUser(prev => ({
...prev,
address: { ...prev.address, city: "Pune" },
}));
The rule I follow is to treat state as immutable. For arrays I use map, filter, spread, or the newer toSorted and toSpliced, which return copies. Even when a mutation happens to "work" because something else triggered a render, it breaks React.memo and effect dependencies, because they also compare references.
When the nesting gets deep, I either flatten the state, move to useReducer, or use Immer, which lets me write mutating syntax that produces a new object.
IntermediateWhat's the difference between useState(compute()) and useState(() => compute())?
So the first one calls compute() on every single render and React throws the result away after the first. The second passes a function, and React only calls it once, on mount. That's called lazy initialisation.
// Bad: parses localStorage on every keystroke
const [draft, setDraft] = useState(JSON.parse(localStorage.getItem("draft")));
// Good: parses once
const [draft, setDraft] = useState(() =>
JSON.parse(localStorage.getItem("draft") ?? "\"\"")
);
In my last project we had a form editor that read a saved draft from localStorage and built an index from it. It was fine at first, but typing started to lag once drafts got big, because every keystroke re-ran the parse. Switching to the function form fixed it.
One gotcha: if I actually want to store a function in state, I have to wrap it, like useState(() => myFn), because React will call anything I pass that's a function. The same applies to the setter: setHandler(() => newFn).
So in practice, I use the lazy form whenever the initial value comes from something non-trivial, like parsing, reading storage or building a big structure.
IntermediateShould I store fullName in state if I already have firstName and lastName?
So no. If a value can be calculated from props or existing state, I calculate it during render. Storing it means I have two sources of truth, and sooner or later they drift apart.
// Bad: redundant state, plus an effect to keep it in sync
const [fullName, setFullName] = useState("");
useEffect(() => {
setFullName(firstName + " " + lastName);
}, [firstName, lastName]);
// Good: derived
const fullName = firstName + " " + lastName;
What's wrong with the first version isn't just the extra code. It renders twice on every change: once with the stale fullName, then again after the effect. You can sometimes see a flash of the old value.
The same goes for filtered lists. I keep items and query in state, and compute visibleItems from them. If the filter is genuinely expensive, I wrap it in useMemo. I don't store it.
A related one I see in code review is copying a prop into state, like useState(props.initialColor). That's fine if it really is just an initial value, but if the parent can change it, the state won't follow. Then I either use the prop directly or reset with a key.
Interviewers look for: "minimal state, derive the rest," and recognising the effect-to-sync-state anti-pattern.
IntermediateHow do you reset a component's state when a prop like userId changes?
So the cleanest way is to give the component a key. When the key changes, React treats it as a different component, unmounts the old one and mounts a fresh one with its initial state.
// Bad: effect-based reset: extra render, easy to miss a field
function Editor({ userId }) {
const [draft, setDraft] = useState("");
useEffect(() => { setDraft(""); }, [userId]);
// ...
}
// Good: key-based reset
<Editor key={userId} userId={userId} />
With the effect version, the first render after switching users still shows the previous user's draft, then the effect clears it. And if someone adds a second piece of state later, they have to remember to reset it too. With the key, everything inside, including child state, starts clean.
In my last project we had a chat view where switching conversations kept the half-typed message from the previous chat. Adding key={conversationId} fixed it in one line.
The trade-off is that a remount also recreates DOM and re-runs effects. For a small subtree that's nothing. If I only need to reset one field out of many, I'd reset that one field in the event handler that changes the user instead.
AdvancedIs setState synchronous or asynchronous? When can I read the new value?
So neither word is quite right. Calling the setter is synchronous in the sense that it queues the update immediately, but it doesn't change the variable I'm holding. My count is a constant for this render. I see the new value on the next render.
function handleClick() {
setCount(count + 1);
console.log(count); // still the old value
setName("Ada");
setFlag(true); // all batched into one re-render
}
async function handleSave() {
await save();
setStatus("saved"); // also batched (React 18+), even after await
setOpen(false);
}
Since React 18, updates are batched automatically everywhere: event handlers, promises, timeouts, native listeners. So several setters in a row cause one re-render, not several.
If I need the next value right away, I compute it into a local variable first and use that: const next = count + 1; setCount(next); send(next);. That's the normal fix.
The rare escape hatch is flushSync from react-dom, which forces React to apply the update and commit to the DOM before returning. I've used it once, to scroll to a just-added list item. It hurts performance, so it's a last resort.
Interviewers look for: the snapshot model, automatic batching in React 18+, and knowing flushSync exists but is rarely right.
12. useEffect, Deeply
BasicWhat is useEffect for, and how does the dependency array control it?
So useEffect is for synchronising my component with something outside React: a server, a timer, a browser API, a WebSocket. It runs after React has committed to the page, and it can return a cleanup that undoes whatever it started.
The dependency array tells React when to re-sync:
| Array | Runs |
|---|---|
| none | after every render |
[] | once after mount, cleanup on unmount |
[roomId] | after mount, and again whenever roomId changes |
useEffect(() => {
const conn = createConnection(roomId);
conn.connect();
return () => conn.disconnect();
}, [roomId]);
What happens here is: on mount I connect to room 1. If roomId changes to 2, React first runs the cleanup, disconnecting from room 1, and then connects to room 2. On unmount it disconnects for good.
The way I think about it is not "run this on mount" but "keep this connection in step with roomId". That framing stops most effect bugs.
Interviewers look for: "synchronising with an external system," not "a place to run code after render."
BasicWhy does my effect run twice in development?
So that's StrictMode. In development only, React mounts the component, immediately simulates an unmount, and mounts it again. Every effect goes setup, cleanup, setup. It's a deliberate test that my cleanup actually undoes my setup.
// Bad: breaks under StrictMode: two listeners, no cleanup
useEffect(() => {
window.addEventListener("resize", onResize);
}, []);
// Good: survives the double run
useEffect(() => {
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, []);
If the double run causes a visible bug, like two chat connections or an analytics event fired twice, then a real user would hit the same bug by navigating away and back. So the fix is to write the cleanup, not to turn StrictMode off.
For things that genuinely can't be undone, like a "purchase" POST, those shouldn't be in an effect at all. They belong in the event handler that caused them. And for one-time app setup, I run it at module level or guard it with a module variable.
Production builds only run it once. Next.js and Vite templates turn StrictMode on by default, which is why people run into this so often.
IntermediateWhat's a race condition in a data-fetching effect, and how do you prevent it?
So if the effect fetches based on a prop, and the prop changes quickly, the requests can resolve out of order. The slow response for the old ID can land after the fast one for the new ID and overwrite the screen with the wrong data.
Let me give you an example: a user clicks profile 1, then profile 2. Request 1 takes 800ms, request 2 takes 100ms. Without cleanup, the screen ends up showing profile 1 under the heading for profile 2.
useEffect(() => {
const controller = new AbortController();
fetch(`/api/users/${id}`, { signal: controller.signal })
.then(r => r.json())
.then(setUser)
.catch(e => {
if (e.name !== "AbortError") setError(e);
});
return () => controller.abort();
}, [id]);
What happens here is that when id changes, React runs the cleanup from the previous effect first, which aborts request 1. Its promise rejects with an AbortError, which I ignore on purpose. Only the latest request can call setUser.
If the API doesn't support abort, say an SDK call, I use an ignore flag instead: set let ignore = false in the effect, flip it to true in cleanup, and check it before setting state.
Interviewers look for: knowing cleanup runs before the next setup, and both the AbortController and the ignore-flag approaches.
IntermediateMy effect runs in an infinite loop. How do you debug it?
So almost always the effect sets state, which re-renders, and something in the dependency array is a new value every render, so the effect runs again. There are three things I check.
// 1. object or array created during render
const options = { page, size };
useEffect(() => {
fetchList(options).then(setList);
}, [options]); // new object every render, loops forever
// fix: depend on primitives
useEffect(() => {
fetchList({ page, size }).then(setList);
}, [page, size]);
First, is a dependency an object, array or function built during render? Each render makes a new one, so Object.is always says "changed". I either depend on the primitive fields, move the object inside the effect, or memoise it.
Second, is there no dependency array at all? Then it runs after every render, and if it sets state, that's a loop.
Third, is the effect setting state that's also in its own deps, like setCount(count + 1) with [count]? Usually that logic belongs in an event handler, or it should use the updater form and drop the dependency.
In practice I open React DevTools with "highlight updates" on, or add a console.log of each dependency to see which one keeps changing. Then the fix is obvious.
IntermediateGive me examples of effects you would delete in a code review.
So my test is: "is this synchronising with something outside React?" If the answer is no, it probably shouldn't be an effect. There are really three patterns I remove.
// 1. derived data: just compute it
useEffect(() => { setTotal(items.reduce((s, i) => s + i.price, 0)); }, [items]);
const total = items.reduce((s, i) => s + i.price, 0);
// 2. reacting to a user action: do it in the handler
useEffect(() => { if (submitted) post(form); }, [submitted]);
function handleSubmit() { post(form); }
// 3. notifying a parent after state changes
useEffect(() => { onChange(isOn); }, [isOn]);
function toggle() {
const next = !isOn;
setIsOn(next);
onChange(next);
}
The first one causes an extra render and a flash of the stale total. The second loses the "why": the effect doesn't know it was a click, and it can fire again on remount. The third one makes the parent update one render late, which causes cascading renders.
A fourth one I see a lot is resetting state when a prop changes, which I'd replace with a key.
So in practice, most codebases I've joined had too many effects. Removing them usually fixes bugs, not just style.
Interviewers look for: familiarity with the "You Might Not Need an Effect" guidance and concrete replacements, not just the slogan.
AdvancedThis counter gets stuck at 1. Why, and what are the fixes?
useEffect(() => {
const id = setInterval(() => setCount(count + 1), 1000);
return () => clearInterval(id);
}, []);
So it's a stale closure. The effect ran once, in the first render, where count was 0. The interval callback closed over that 0 forever. So every tick sets the count to 0 + 1, which is 1.
Leaving count out of the deps didn't stop the effect needing it. It just made it use an old one. There are a few fixes depending on the case:
// 1. updater form: no need to read count at all
setInterval(() => setCount(c => c + 1), 1000);
// 2. need to read a prop or state without re-subscribing:
// useEffectEvent (stable since React 19.2)
const onTick = useEffectEvent(() => {
setCount(count + step); // always sees latest count and step
});
useEffect(() => {
const id = setInterval(() => onTick(), 1000);
return () => clearInterval(id);
}, []);
The updater form is my first choice because it's the simplest. When the callback has to read several changing values but I don't want to tear down and restart the interval every time, useEffectEvent is the right tool. Before it existed, the pattern was to store the latest callback in a ref and read ref.current inside the interval.
What I wouldn't do is silence the lint rule. The react-hooks/exhaustive-deps warning was pointing at exactly this bug.
Interviewers look for: naming it a stale closure, the updater fix, and awareness of useEffectEvent or the latest-ref pattern.
AdvancedWould you still fetch data in useEffect in a new app today?
So for a new app, mostly no. Raw effect-based fetching works, but I end up rebuilding a lot by hand: race handling, caching, deduplication, retries, loading and error states, refetch on focus. And it only starts after the component renders, so nested components create request waterfalls.
What I'd reach for depends on the setup:
| Setup | What I use |
|---|---|
| Next.js App Router | Fetch in Server Components, mutate with Server Actions |
| Client-side SPA | TanStack Query or SWR |
| Router with loaders | React Router or TanStack Router loaders |
// TanStack Query: caching, dedupe and races handled for me
const { data, isPending, error } = useQuery({
queryKey: ["user", id],
queryFn: () => fetch(`/api/users/${id}`).then(r => r.json()),
});
In my last project we moved a dashboard from hand-written effects to TanStack Query. We deleted a few hundred lines of loading-state code, and two widgets that both requested the same user now share one request.
I'd still write a plain effect for something small, like a one-off widget, and when I do, I always add the abort cleanup. So effects aren't wrong for fetching, they're just the low-level primitive.
13. React Component Lifecycle
BasicWalk me through the lifecycle of a function component.
So there are three stages: mount, update and unmount. Mount is when it first appears, update is every re-render after that, and unmount is when it's removed and its state is thrown away.
Let me give you a concrete one. Think of {isOpen && <Modal />}:
function Modal() {
useEffect(() => {
document.body.style.overflow = "hidden"; // mount: lock scroll
return () => {
document.body.style.overflow = ""; // unmount: unlock
};
}, []);
return <div role="dialog">...</div>;
}
When isOpen becomes true, the modal mounts: React calls the function, creates the DOM, the browser paints, then my effect locks scrolling. While it's open, any state change is an update: React calls the function again and patches the DOM, but this effect doesn't re-run because its deps are []. When isOpen becomes false, it unmounts: the DOM is removed and the cleanup unlocks scrolling.
If it opens again later, it's a completely fresh mount with initial state. Nothing is remembered from last time.
Interviewers look for: the three stages, and that unmount means state is gone for good.
BasicHow do the class lifecycle methods map to hooks?
So most of them collapse into useEffect, with the dependency array deciding which stage I'm talking about.
| Class | Hooks |
|---|---|
constructor | useState(initial) or a lazy initialiser; useRef for non-visual values |
componentDidMount | useEffect(fn, []) |
componentDidUpdate | useEffect(fn, [deps]), which also runs on mount |
componentWillUnmount | the cleanup returned from the effect |
shouldComponentUpdate | React.memo |
getDerivedStateFromProps | compute during render, or reset with a key |
componentDidCatch | no hook; still a class error boundary |
One thing I'd point out is the mindset shift. With classes, a subscription was split across three methods: subscribe in did-mount, resubscribe in did-update, unsubscribe in will-unmount. With hooks it's one effect with a cleanup, keyed on what it depends on.
useEffect(() => {
const unsub = store.subscribe(channelId, onMessage);
return unsub;
}, [channelId]);
That covers all three stages, and it's actually harder to get wrong than the class version, where people often forgot the did-update part.
Interviewers look for: the React.memo gotcha too: its compare function returns true when props are equal, the opposite of shouldComponentUpdate.
IntermediateWhat causes a component to re-render?
So there are really three triggers: its own state was set to a new value, its parent re-rendered, or a context it reads changed. That's it.
People usually say "its props changed", but props only change because the parent re-rendered, so that's really trigger two. And by default, a child re-renders when its parent does, even if its props are identical.
function Parent() {
const [n, setN] = useState(0);
return (
<>
<button onClick={() => setN(n + 1)}>{n}</button>
<Sidebar /> {/* re-renders on every click, no props at all */}
</>
);
}
What doesn't trigger a re-render: changing a regular variable, changing ref.current, or setting state to the same value, since React compares with Object.is and bails out.
And there's one special case: if the key or the component type changes, that's not a re-render, it's an unmount and a new mount.
So in practice, when I'm chasing unnecessary renders, I use the React DevTools Profiler, which tells me why each component rendered. Then I fix it by moving state down, passing components as children, or using React.memo where it's actually expensive.
IntermediateWhat's the difference between the render phase and the commit phase? Why must render be pure?
So the render phase is React calling my components and working out what changed. Nothing on screen changes yet. The commit phase is React applying those changes to the DOM in one go, setting refs, and then running effects.
Render has to be pure because React is allowed to call it more than once, pause it, or throw the result away. With concurrent features like useTransition, a render can be interrupted by a more urgent update and restarted. StrictMode also calls it twice in development to catch impurity.
let renderCount = 0;
function Item({ item }) {
renderCount++; // Bad: side effect during render
analytics.track("view"); // Bad: may fire twice or for a discarded render
return <li>{item.name}</li>;
}
function Item({ item }) {
useEffect(() => {
analytics.track("view"); // Good: only after a real commit
}, []);
return <li>{item.name}</li>;
}
One gotcha I've hit: mutating a prop array during render, like calling items.sort() directly. It changed the parent's data and caused weird ordering bugs. I use toSorted() or copy first.
The commit is never partial. The user never sees half the DOM updated, which is the guarantee that makes concurrent rendering safe.
Interviewers look for: "render may run many times or be thrown away; commit runs once."
IntermediateWhen would you use useLayoutEffect instead of useEffect?
So the difference is timing. useLayoutEffect runs after the DOM is updated but before the browser paints. useEffect runs after the paint. I use the layout version only when I need to measure the DOM and adjust something before the user sees it.
The classic example is a tooltip that needs to flip above the anchor if there's no room below.
function Tooltip({ anchorRect, children }) {
const ref = useRef(null);
const [top, setTop] = useState(anchorRect.bottom);
useLayoutEffect(() => {
const h = ref.current.getBoundingClientRect().height;
if (anchorRect.bottom + h > window.innerHeight) {
setTop(anchorRect.top - h); // re-renders before paint
}
}, [anchorRect]);
return <div ref={ref} className="tooltip" style={{ top }}>{children}</div>;
}
What happens here is that React commits the tooltip below, my layout effect measures it, sees it overflows, and sets state. React re-renders synchronously, still before the paint. So the user only ever sees the final position. With useEffect, they'd see a one-frame flicker from below to above.
The cost is that it blocks painting, so it has to be fast. And neither hook runs on the server, so for SSR I make sure the initial render is reasonable without it.
So in practice, useEffect is my default, and useLayoutEffect is for measure-then-position cases only.
AdvancedIn what order do effects and cleanups run across parents and children?
So two rules. Within one commit, React runs all the cleanups first, then all the new setups. And children's effects run before their parent's, because React finishes the tree bottom-up.
function Parent() {
useEffect(() => {
console.log("parent setup");
return () => console.log("parent cleanup");
});
return <Child />;
}
function Child() {
useEffect(() => {
console.log("child setup");
return () => console.log("child cleanup");
});
return null;
}
// mount: child setup, parent setup
// re-render: child cleanup, parent cleanup,
// child setup, parent setup
What that means practically is that in a parent's effect, I can rely on the children already being in the DOM and their effects having run. That's why a parent can measure a child's element in its own effect.
The other detail is that cleanups see the old values. A cleanup is a closure from the render it was created in, so if userId went from 1 to 2, the cleanup logs 1. That's what you want: you're undoing the old subscription.
And useLayoutEffect cleanups and setups all run before paint, while useEffect ones run after. So a layout effect in the parent still runs before a passive effect in the child.
Interviewers look for: "cleanups before setups, children before parents, cleanups see old values."
AdvancedIs there anything you still need class components for?
So yes, two things, and they're both edge cases. Error boundaries still need a class, because there's no hook for componentDidCatch or getDerivedStateFromError. And getSnapshotBeforeUpdate, reading something from the DOM right before it changes, also has no direct hook.
class ErrorBoundary extends React.Component {
state = { error: null };
static getDerivedStateFromError(error) {
return { error }; // render fallback next time
}
componentDidCatch(error, info) {
logToSentry(error, info.componentStack);
}
render() {
return this.state.error ? this.props.fallback : this.props.children;
}
}
In practice I don't write that myself. I use the react-error-boundary package, which wraps it and gives me resetKeys and a useErrorBoundary hook to push async errors into the boundary. In Next.js App Router, an error.tsx file creates one for each route segment.
One thing I always mention: error boundaries only catch errors during rendering, lifecycle and constructors. They don't catch errors in event handlers or in a setTimeout. For those I use try/catch.
For the snapshot case, like keeping scroll position in a chat when new messages arrive above, I get close with a ref that stores the scroll height plus useLayoutEffect to restore it before paint.
14. useRef
BasicWhat is useRef, and how is it different from state?
So useRef gives me a box with one property, .current, that survives re-renders. The key difference from state is that changing it never causes a re-render. React isn't told.
| useState | useRef | |
|---|---|---|
| Changing it re-renders | Yes | No |
| Value in this render | Snapshot, fixed | Mutable, always latest |
| Use for | What the user sees | Timers, DOM nodes, bookkeeping |
function Stopwatch() {
const [elapsed, setElapsed] = useState(0); // shown on screen
const intervalRef = useRef(null); // just an id
function start() {
intervalRef.current = setInterval(() => setElapsed(e => e + 1), 1000);
}
function stop() {
clearInterval(intervalRef.current);
}
// ...
}
What happens here is the interval id is something I need later to stop the timer, but nobody sees it. If I put it in state, every start would cause a pointless re-render. The elapsed time is shown, so that's state.
My test is simple: if changing it should update the screen, it's state. If not, it's a ref.
BasicHow do you focus an input when a component mounts?
So I attach a ref to the input and call focus() in an effect. React fills in ref.current with the real DOM node during the commit, so by the time the effect runs, it's there.
function SearchDialog() {
const inputRef = useRef(null);
useEffect(() => {
inputRef.current.focus();
}, []);
return <input ref={inputRef} placeholder="Search" />;
}
For this exact case there's also the autoFocus prop, which React handles. But the ref pattern is the general one, and I use it for anything React doesn't model declaratively: scrolling an element into view, measuring its size, playing a video, or handing a DOM node to a chart library like D3.
One gotcha: inputRef.current is null during the first render, because the element doesn't exist yet. So I never read it in the function body, only in effects or event handlers.
Interviewers look for: knowing when ref.current is populated (after commit), and that refs are the escape hatch for imperative DOM work.
IntermediateWhy is reading or writing ref.current during render a bad idea?
So because render is supposed to be pure, and a ref is mutable data React doesn't track. If I read it during render, the output depends on something that can change without React knowing, so the screen can show stale values. If I write it during render, that write can happen twice or for a render that gets thrown away.
// Bad: screen won't update when this changes
function Clicks() {
const count = useRef(0);
return <button onClick={() => count.current++}>{count.current}</button>;
}
// Bad: writing during render: counts StrictMode and discarded renders too
function Tracker() {
const renders = useRef(0);
renders.current++;
// ...
}
In the first one the number just never changes on screen, because incrementing the ref doesn't re-render. That's a state job.
The one accepted exception is lazy initialisation of a ref: if (ref.current === null) ref.current = new ExpensiveThing();. That's safe because it gives the same result every time.
So in practice, I read and write refs in event handlers and effects. The React Compiler and its lint rules also flag reading refs during render, which is another reason to keep this habit.
IntermediateHow would you track the previous value of a prop?
So the classic answer is a ref updated in an effect: during render, the ref still holds the last committed value, then the effect updates it after commit.
function usePrevious(value) {
const ref = useRef();
useEffect(() => {
ref.current = value;
}, [value]);
return ref.current;
}
function Price({ amount }) {
const prev = usePrevious(amount);
const trend = prev === undefined ? "" : amount > prev ? "up" : "down";
return <span className={trend}>{amount}</span>;
}
I'd mention that this pattern reads a ref during render, which the current React docs discourage. The alternative they recommend is storing the previous value in state and updating it during render with a guard:
const [prev, setPrev] = useState(amount);
const [trend, setTrend] = useState("");
if (amount !== prev) {
setPrev(amount);
setTrend(amount > prev ? "up" : "down");
}
That looks odd, but React handles a setState during render of the same component by re-running it immediately, before touching the DOM. In my last project we used the ref version for a stock ticker and it worked fine. For new code I'd lean towards the state version because it's compiler-friendly.
Interviewers look for: the usePrevious hook, and bonus points for knowing why it's now considered slightly impure.
IntermediateHow do you pass a ref to your own component? Has that changed recently?
So yes, it changed in React 19. Before, a ref on a custom component didn't reach it as a prop, so I had to wrap the component in forwardRef. In React 19, ref is just a regular prop for function components.
// React 18 and earlier
const TextInput = forwardRef(function TextInput(props, ref) {
return <input ref={ref} {...props} />;
});
// React 19
function TextInput({ ref, ...props }) {
return <input ref={ref} {...props} />;
}
// usage is the same
const inputRef = useRef(null);
<TextInput ref={inputRef} />
If I don't want to expose the whole DOM node, I use useImperativeHandle to expose a small API instead:
function VideoPlayer({ ref, src }) {
const videoRef = useRef(null);
useImperativeHandle(ref, () => ({
play: () => videoRef.current.play(),
pause: () => videoRef.current.pause(),
}), []);
return <video ref={videoRef} src={src} />;
}
That way the parent can call play() and pause() but can't change styles on the node directly. I use this for design-system components like a Modal with open(). It's still an escape hatch though. If something can be a prop, like isPlaying, I'd rather make it a prop.
AdvancedWhat's a callback ref, and when is it better than useRef?
So a callback ref is a function passed to ref instead of a ref object. React calls it with the DOM node when it attaches, and with null (or runs its cleanup) when it detaches. I use it when I need to react to the node appearing, which a plain ref object can't tell me.
Let me give you an example. I need to measure an element that only renders after data loads:
// Bad: runs once on mount, before the list exists
const ref = useRef(null);
useEffect(() => { measure(ref.current); }, []);
// Good: runs whenever the node actually attaches
function List({ items }) {
const [height, setHeight] = useState(0);
const measureRef = useCallback(node => {
if (!node) return;
const ro = new ResizeObserver(([e]) => setHeight(e.contentRect.height));
ro.observe(node);
return () => ro.disconnect(); // React 19: ref cleanup
}, []);
return items ? <ul ref={measureRef}>...</ul> : <Spinner />;
}
What happens here is that when the <ul> finally mounts, React calls measureRef with it, and I start observing. In React 19 the callback can return a cleanup function, which React calls when the node goes away. Before 19, I'd handle the null call myself.
One gotcha: if the callback is an inline arrow, it's a new function every render, so React detaches and reattaches each time. That's why I wrap it in useCallback.
Interviewers look for: the conditional-element measuring case and the React 19 ref cleanup.
15. useMemo & useCallback
BasicWhat's the difference between useMemo and useCallback?
So they're the same tool. useMemo caches the value returned by a function. useCallback caches the function itself. Both return the cached thing until a dependency changes.
const sorted = useMemo(() => [...items].sort(byName), [items]);
const onSave = useCallback(() => save(id), [id]);
// literally equivalent:
useCallback(fn, deps) === useMemo(() => fn, deps)
What people get wrong is thinking useCallback makes the function faster or stops it being created. The arrow function is still created every render; React just hands me back the old one if id hasn't changed. The benefit is purely that the identity stays the same.
So useMemo is mainly for skipping expensive work, and useCallback is for keeping a function's identity stable, so a memoised child or an effect dependency doesn't see it as changed.
Interviewers look for: "value vs function," and understanding that useCallback is about identity, not speed.
IntermediateWhen does useCallback actually make a difference?
So only when something downstream compares the function by reference. On its own, wrapping a handler for a plain <button> does nothing useful. There are really two cases where it matters.
// Case 1: passed to a memoised child
const Row = memo(function Row({ item, onSelect }) { ... });
function List({ items }) {
const [selected, setSelected] = useState(null);
const onSelect = useCallback(id => setSelected(id), []);
return items.map(i => <Row key={i.id} item={i} onSelect={onSelect} />);
}
// Case 2: used as an effect dependency
const fetchUser = useCallback(() => api.get(`/users/${id}`), [id]);
useEffect(() => { fetchUser().then(setUser); }, [fetchUser]);
In the first case, without useCallback, every render makes a new onSelect, so every Row's memo check fails and all 500 rows re-render. With it, only the row whose item changed re-renders.
In the second case, an unstable function would re-run the effect every render. Though honestly, for case two I'd usually just move the function inside the effect and depend on id.
So in practice, useCallback and memo go together. One without the other is usually pointless.
IntermediateShould you wrap every value and function in useMemo and useCallback?
So no. Memoising isn't free. React has to store the value, keep the deps array, and compare every dependency on every render. For cheap work, that overhead costs more than it saves, and it makes the code noisier.
// Bad: pointless: string concat is cheaper than the memo bookkeeping
const label = useMemo(() => `${first} ${last}`, [first, last]);
// Good: worth it: 20k rows, fuzzy match
const results = useMemo(
() => rows.filter(r => fuzzyMatch(r.name, query)),
[rows, query]
);
My rule is: I memoise when I've measured something slow, roughly anything over a millisecond in the Profiler, or when I specifically need a stable identity for a memo child, a context value or an effect dependency.
Also, the dependencies have to actually be stable. I've seen useMemo(() => ..., [options]) where options was an object literal from the parent, so it recomputed every render anyway and just added overhead.
And with the React Compiler, a lot of this happens automatically, so hand-written memoisation is becoming less necessary in codebases that have it turned on.
Interviewers look for: a measured, not reflexive, approach, and knowing memo only works if the deps are stable.
IntermediateI wrapped a child in React.memo, but it still re-renders every time. What would you check?
So memo does a shallow comparison of every prop with Object.is. If any single prop is a new reference, the memo does nothing. There are three things I check.
const Chart = memo(Chart_);
function Dashboard({ data }) {
return (
<Chart
data={data}
options={{ legend: true }} // 1. new object every render
onHover={p => setHovered(p)} // 2. new function every render
>
<Legend /> {/* 3. children is a new element */}
</Chart>
);
}
First, inline objects and arrays like options. I hoist constants outside the component or wrap them in useMemo. Second, inline functions like onHover, which need useCallback. Third, and people miss this one, children is a prop too, and JSX creates a new element object every render, so passing children to a memoised component usually defeats it.
Beyond props, a memoised component still re-renders if its own state changes or a context it reads changes. So if it calls useContext on something busy, memo won't help.
In practice I confirm it with the React DevTools Profiler, which shows "why did this render" and names the prop that changed.
AdvancedCan you rely on useMemo to run your calculation only once?
So no. React treats useMemo as a performance hint, not a guarantee. The docs explicitly say React may throw away the cached value, for example during development edits, when a component suspends on its first mount, or in future features. So my code has to be correct even if the function runs again.
// Bad: relying on memo for correctness
const connection = useMemo(() => createSocket(url), [url]);
// might create two sockets, and nothing ever closes them
// Good: it's a side effect with a lifetime: use an effect
useEffect(() => {
const socket = createSocket(url);
return () => socket.close();
}, [url]);
// Good: need exactly one instance per component: lazy ref
const idRef = useRef(null);
if (idRef.current === null) idRef.current = crypto.randomUUID();
Also, in StrictMode, React calls the useMemo function twice in development to check it's pure. If creating the thing has side effects, I'll see them doubled.
So my rule is: if removing useMemo would break behaviour, not just slow things down, I'm using the wrong tool. For "exactly once" I use a ref or state initialiser, and for things with setup and teardown, an effect.
Interviewers look for: "semantic guarantee vs performance hint." This separates people who've read the docs closely.
AdvancedHow does the React Compiler change how you write useMemo and useCallback?
So the React Compiler, which hit 1.0 in late 2025, is a build-time plugin that analyses my components and inserts memoisation automatically. It can memoise at a finer grain than I would by hand, including individual JSX elements, so most manual useMemo, useCallback and memo become unnecessary.
// I write this
function TodoList({ todos, filter }) {
const visible = todos.filter(t => t.status === filter);
const onToggle = id => toggle(id);
return <List items={visible} onToggle={onToggle} />;
}
// the compiler effectively caches `visible`, `onToggle`
// and the <List> element, keyed on todos and filter
The catch is it assumes I follow the rules of React: pure render, no mutating props or state, no reading refs during render. If a component breaks those rules, the compiler skips it rather than risk changing behaviour. So it pushes code toward being cleaner.
In my last project we enabled it gradually, directory by directory, with the lint rules first to find violations. We left existing useMemo calls in place, since removing them can change what the compiler does, and stopped adding new ones.
I'd still reach for manual memoisation where I need a guaranteed stable identity for a third-party library, or where the compiler bails out on a file.
16. useReducer & useContext
BasicWhat is useReducer, and when would you pick it over useState?
So useReducer moves the state update logic into one pure function. Instead of calling setters, the component dispatches an action describing what happened, and the reducer decides what the new state is.
I pick it when several pieces of state change together, or when separate useState calls would let me create impossible combinations.
// Bad: three setters: loading and error can both be true
const [loading, setLoading] = useState(false);
const [data, setData] = useState(null);
const [error, setError] = useState(null);
// Good: every valid state is written down once
function reducer(state, action) {
switch (action.type) {
case "start": return { status: "loading", data: null, error: null };
case "success": return { status: "success", data: action.data, error: null };
case "failure": return { status: "error", data: null, error: action.error };
default: return state;
}
}
const [state, dispatch] = useReducer(reducer, { status: "idle", data: null, error: null });
What I like is that the component just says "loading started", and the reducer owns the rules. It's also trivially testable, because it's a plain function: reducer(state, action) in, new state out, no React needed.
For a single toggle or an input value, useState is simpler, and I use that.
Interviewers look for: "related state that changes together" and "impossible states," not just "complex state."
BasicWhat problem does context solve?
So context solves prop drilling: passing a value through five layers of components that don't use it, just so one deep child can read it. With context, a provider makes the value available to everything below it, and any component can read it directly with useContext.
const ThemeContext = createContext("light");
function App() {
const [theme, setTheme] = useState("dark");
return (
<ThemeContext value={theme}> {/* React 19: no .Provider needed */}
<Layout />
</ThemeContext>
);
}
function Button() {
const theme = useContext(ThemeContext);
return <button className={theme}>Save</button>;
}
What happens here is that Layout and everything between it and Button never mention the theme. Button finds the nearest provider above it. If there's none, it gets the default, "light".
I use context for things that are genuinely global and change rarely: theme, locale, the logged-in user, feature flags. In React 19 the context itself works as the provider, though .Provider still works.
IntermediateChanging one value in our context re-renders half the app. Why, and how do you fix it?
So every component that reads a context re-renders whenever the context value changes, and "changes" means a new reference. There's no selector, so a component that only needs user.name still re-renders when cart changes. There are three fixes I use.
// Bad: new object every render: ALL consumers re-render every time
<AppContext value={{ user, setUser, cart, setCart }}>
// 1. memoise the value
const value = useMemo(() => ({ user, setUser }), [user]);
<UserContext value={value}>
// 2. split by how often things change
<UserContext value={userValue}>
<CartContext value={cartValue}>
...
// 3. separate state from dispatch
<TodosContext value={todos}>
<TodosDispatchContext value={dispatch}> {/* dispatch never changes */}
In my last project, we had one big AppContext with the cart inside. Every "add to cart" re-rendered the header, sidebar and product grid. Splitting the cart into its own context and memoising the value fixed it.
The dispatch split is nice because components that only trigger actions, like an "Add" button, read the dispatch context and never re-render when the list changes.
If after all that it's still fast-changing data read in many places, context is the wrong tool, and I'd move it to a store with selectors.
Interviewers look for: knowing context has no selectors, and the memo, split and dispatch-context fixes.
IntermediateWhen would you use Redux Toolkit or Zustand instead of context?
So context is a way to pass a value down, not a state manager. It has no selectors, so every consumer re-renders on any change. A store like Zustand or Redux Toolkit lets each component subscribe to just the slice it needs, and only re-render when that slice changes.
// Zustand
const useCart = create(set => ({
items: [],
add: item => set(s => ({ items: [...s.items, item] })),
}));
function CartBadge() {
const count = useCart(s => s.items.length); // re-renders only when count changes
return <span>{count}</span>;
}
Here's how I decide:
| Situation | Choice |
|---|---|
| Theme, locale, auth user | Context |
| Server data (lists, profiles) | TanStack Query, or Server Components |
| Shared client state that changes often | Zustand |
| Large team, complex flows, need devtools and strict patterns | Redux Toolkit |
The thing I'd stress is that most "global state" in older apps was actually server cache. Once that moves into TanStack Query or Server Components, what's left is often small enough for context or a tiny Zustand store.
IntermediateHow else can you avoid prop drilling without reaching for context?
So often the answer is composition: pass components as children or as props, so the component that owns the data renders the one that needs it directly, and the layers in between never see it.
// Bad: drilling user through Layout and Sidebar
<Layout user={user} />
function Layout({ user }) { return <Sidebar user={user} />; }
function Sidebar({ user }) { return <Avatar user={user} />; }
// Good: composition: Layout just renders a slot
<Layout sidebar={<Sidebar><Avatar user={user} /></Sidebar>} />
function Layout({ sidebar, children }) {
return <div className="grid">{sidebar}{children}</div>;
}
What happens here is App already has user, so it builds <Avatar user={user} /> itself and hands the finished element down. Layout doesn't know or care what's inside.
There's a performance bonus too. If Layout has its own state and re-renders, the sidebar element was created by the parent, so it's the same reference and React skips re-rendering it.
So in practice, I try composition first. Two or three levels of props is fine, it's explicit. I reach for context when the value is needed in many unrelated places.
Interviewers look for: composition as the first answer. Jumping straight to context or Redux is a yellow flag for seniors.
AdvancedCan a reducer make an API call or generate a random ID?
So no. A reducer has to be pure: same state and action in, same state out, no side effects, no mutation. React relies on that. In StrictMode it calls reducers twice in development to catch impurity, and in concurrent rendering it may re-run them.
// Bad: impure reducer
case "add":
api.post("/todos", action.text); // fires twice in dev
return [...state, { id: crypto.randomUUID(), text: action.text }];
// different id each call
// Good: side effect and randomness in the handler
function handleAdd(text) {
const todo = { id: crypto.randomUUID(), text };
dispatch({ type: "add", todo });
api.post("/todos", todo);
}
case "add":
return [...state, action.todo];
What happens with the bad version is that in development I'd see two POSTs, and the two calls would generate different IDs, so the one React keeps might not match what I sent to the server.
The pattern I follow is: the event handler does the impure work, like generating IDs, reading the time or calling the API, and puts the results in the action. The reducer just computes the next state. It also means I can unit test the reducer with no mocks at all.
Same rule applies to setState updater functions. They get double-called in StrictMode too.
AdvancedHow would you structure app-level state with useReducer and context together?
So the pattern is: one reducer holds the state, and I expose it through two contexts, one for the state and one for dispatch, wrapped in a provider component plus small custom hooks.
const TasksContext = createContext(null);
const TasksDispatchContext = createContext(null);
export function TasksProvider({ children }) {
const [tasks, dispatch] = useReducer(tasksReducer, []);
return (
<TasksContext value={tasks}>
<TasksDispatchContext value={dispatch}>
{children}
</TasksDispatchContext>
</TasksContext>
);
}
export function useTasks() {
const ctx = useContext(TasksContext);
if (ctx === null) throw new Error("useTasks must be inside TasksProvider");
return ctx;
}
export const useTasksDispatch = () => useContext(TasksDispatchContext);
The reason I split them is that dispatch is guaranteed stable by React, so components that only send actions never re-render when tasks change. The custom hooks hide the context objects, and the null check gives a clear error if someone forgets the provider.
I've used this for things like a multi-step checkout wizard, where state is shared across steps but scoped to one page. It scales well to a feature. For truly app-wide, frequently changing state I'd switch to Zustand, because this pattern still has no selectors, so every useTasks() consumer re-renders on every change.
Interviewers look for: the state/dispatch split, a provider-plus-hooks API, and knowing where the pattern stops scaling.
17. Custom Hooks
BasicWhat is a custom hook, and why must its name start with use?
So a custom hook is just a function that calls other hooks, so I can reuse stateful logic across components. There's nothing special in React for it. The use prefix is a convention, but an important one: it's how the linter and the React Compiler know to apply the rules of hooks to it.
function useOnlineStatus() {
const [online, setOnline] = useState(navigator.onLine);
useEffect(() => {
const on = () => setOnline(true);
const off = () => setOnline(false);
window.addEventListener("online", on);
window.addEventListener("offline", off);
return () => {
window.removeEventListener("online", on);
window.removeEventListener("offline", off);
};
}, []);
return online;
}
function SaveButton() {
const online = useOnlineStatus();
return <button disabled={!online}>{online ? "Save" : "Offline"}</button>;
}
What happens here is the component reads like "am I online?" and all the listener wiring lives in the hook. If I called it getOnlineStatus, the linter wouldn't check that it's called at the top level, and it would look like a normal function you can call anywhere.
The flip side matters too: if a function doesn't call any hooks, it shouldn't start with use. Then it's just a utility.
BasicIf two components use the same custom hook, do they share state?
So no. A custom hook shares logic, not state. Each call runs the hooks inside it fresh for that component, so each component gets its own independent state. It's a recipe, not a shared box.
function useToggle(initial = false) {
const [on, setOn] = useState(initial);
const toggle = useCallback(() => setOn(o => !o), []);
return [on, toggle];
}
function Settings() {
const [emailOn, toggleEmail] = useToggle(); // own state
const [smsOn, toggleSms] = useToggle(); // separate state
// toggling email does nothing to sms
}
Even within one component, calling it twice gives two independent toggles, like here.
If I actually want shared data, say two components showing the same cart count, the custom hook needs to read from something shared: a context, a store like Zustand, or a query cache. The hook can wrap that, like useCart() reading from a context, but the sharing comes from the context, not from the hook.
Interviewers look for: "hooks share logic, not state." It's a very common junior misconception.
IntermediateWrite a useDebounce hook and walk me through it.
So debouncing means waiting until a value stops changing for a moment before using it. For search, that means one request when the user pauses, not one per keystroke.
function useDebounce(value, delay = 300) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const t = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(t);
}, [value, delay]);
return debounced;
}
function Search() {
const [input, setInput] = useState("");
const query = useDebounce(input, 400);
const { data } = useQuery({
queryKey: ["search", query],
queryFn: () => searchApi(query),
enabled: query.length > 1,
});
return <input value={input} onChange={e => setInput(e.target.value)} />;
}
What happens is: each keystroke changes value, so React runs the cleanup from the previous effect, which cancels the pending timer, and starts a new one. Only when the user stops for 400ms does a timer survive long enough to call setDebounced. Then query changes and the search runs once.
The input stays responsive because input updates immediately. Only the derived query lags.
The cleanup is the whole trick here. Without it, every keystroke would eventually fire. In React 18+ there's also useDeferredValue, which I'd use when the slow part is rendering, not a network call.
IntermediateHow do you decide between a custom hook and a plain utility function?
So the rule is: if the logic needs React, meaning state, effects, context or refs, it's a hook. If it's a pure calculation, it's a plain function. Making something a hook when it doesn't need to be just adds rules, like "only call at the top level", for no reason.
// Bad: not a hook, no React inside
function useFormatPrice(cents, currency) {
return new Intl.NumberFormat("en-IN", { style: "currency", currency })
.format(cents / 100);
}
// Good: plain function, callable anywhere, even in a loop
function formatPrice(cents, currency) { ... }
// Good: genuinely a hook: reads locale from context
function useFormatPrice() {
const { locale, currency } = useContext(LocaleContext);
return cents => new Intl.NumberFormat(locale, { style: "currency", currency })
.format(cents / 100);
}
When I'm deciding whether to extract a hook at all, I look for two signals: the same useState plus useEffect combination showing up in several components, or a component where so much setup logic sits at the top that I can't find the JSX.
What I avoid is lifecycle-style hooks like useMount. They hide what's being synchronised. A hook should be named for its purpose, like useChatRoom(roomId), not for when it runs.
Interviewers look for: "if it doesn't call a hook, it isn't a hook," and naming hooks by purpose.
IntermediateHow do you test a custom hook?
So I use renderHook from @testing-library/react. It mounts a tiny test component that calls my hook and gives me back the result. For updates I wrap them in act, and for timers I use fake timers.
import { renderHook, act } from "@testing-library/react";
test("debounces the value", () => {
vi.useFakeTimers();
const { result, rerender } = renderHook(
({ value }) => useDebounce(value, 300),
{ initialProps: { value: "a" } }
);
rerender({ value: "ab" });
expect(result.current).toBe("a"); // not yet
act(() => { vi.advanceTimersByTime(300); });
expect(result.current).toBe("ab"); // now
});
What happens here is rerender simulates the parent passing a new value. Before the timer fires, the hook still returns the old one. After advancing 300ms inside act, React processes the state update and result.current has the new value.
If the hook needs context, I pass a wrapper option with the provider. And honestly, for hooks that are only used by one component, I often test through that component instead, because that tests real behaviour rather than implementation.
That older @testing-library/react-hooks package is deprecated. renderHook has been in the main library since React 18.
AdvancedHow would you write a hook that subscribes to an external store or browser API safely?
So the right tool is useSyncExternalStore. It's built for subscribing to data that lives outside React, and unlike a useState plus useEffect combo, it avoids tearing in concurrent rendering, where different components could show different values from the same store in one render.
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
() => true // server snapshot, for SSR
);
}
What happens here is React calls subscribe once and passes a callback. Whenever the browser fires an event, React calls getSnapshot, compares it with the last value, and re-renders only if it changed.
Two gotchas I've hit. First, subscribe has to be a stable function, so I define it outside the component. Otherwise React resubscribes every render. Second, getSnapshot must return the same reference if nothing changed. If it returns a new object each time, like { ...store.state }, React sees a change on every check and you get an infinite loop.
This is what libraries like Zustand and Redux use under the hood. I reach for it for things like media queries, localStorage sync across tabs or a WebSocket-backed store.
Interviewers look for: naming useSyncExternalStore, "tearing," and the stable-snapshot gotcha.
AdvancedWhat makes a well-designed custom hook API?
So I think about it the way I'd think about any function signature someone else will call. There are really four things I check.
// return shape: tuple when it's useState-like and callers rename
const [isOpen, toggleOpen] = useToggle();
// object when there are many fields and callers pick some
const { data, error, isPending, refetch } = useUser(id);
// stable functions so callers can put them in deps
function useCounter() {
const [n, setN] = useState(0);
const inc = useCallback(() => setN(x => x + 1), []);
const reset = useCallback(() => setN(0), []);
return { n, inc, reset };
}
First, the return shape: a tuple for two-value, useState-like hooks, because renaming is easy. An object for anything bigger, because order-based destructuring of five values is error-prone.
Second, stable identities. If I return functions or objects, I memoise them, because callers will put them in effect deps or pass them to memoised children. An unstable return value from a hook causes bugs far away from the hook.
Third, inputs as values, not objects created inline, so my internal effects don't re-run every render.
Fourth, one purpose per hook. If useUser also starts tracking analytics, I split it.
In my last project we had a useFilters hook returning a new object each render, and three consumers had effects that re-fetched constantly. Memoising the return value fixed all three at once.
18. The Rules of Hooks
BasicWhat are the rules of hooks?
So there are two. First, only call hooks at the top level of a component or custom hook: never inside conditions, loops, nested functions, or after an early return. Second, only call them from React function components or other custom hooks, not from regular functions or class components.
function Profile({ userId, isAdmin }) {
const [tab, setTab] = useState("info"); // Good: top level
const user = useUser(userId); // Good: custom hook
if (isAdmin) {
const [audit, setAudit] = useState([]); // Bad: inside a condition
}
function handleClick() {
const theme = useContext(ThemeContext); // Bad: inside a handler
}
// ...
}
Both rules come from the same place: React identifies hooks by the order they're called, so it needs the same hooks in the same order on every render, and it needs to know which component is rendering when a hook is called.
In practice I don't have to remember these manually. The eslint-plugin-react-hooks rule rules-of-hooks catches them, and every React or Next.js project I set up has it enabled as an error.
BasicWhy can't you call a hook inside an if?
So React doesn't know hooks by name. It stores their values in a list for each component and matches them by call order: first hook gets slot 1, second gets slot 2, and so on. If a condition skips one, every hook after it reads the wrong slot.
Let me walk through it.
function Form({ showEmail }) {
const [name, setName] = useState(""); // slot 1
if (showEmail) {
const [email, setEmail] = useState(""); // slot 2, sometimes
}
const [age, setAge] = useState(0); // slot 2 or 3
}
// render 1, showEmail = true: name=1, email=2, age=3
// render 2, showEmail = false: name=1, age reads slot 2 (email's value!)
What happens on render 2 is that age now gets the string stored for email. With effects it's worse: an effect could get a different effect's dependency list. In practice, React detects that the hook count changed and throws "Rendered fewer hooks than expected".
The fix is to always call the hook and put the condition inside, or in what I do with the value:
const [email, setEmail] = useState("");
// render the email field only if showEmail
Interviewers look for: "hooks are matched by call order." Once you say that, both rules follow.
IntermediateWhat's wrong with this component?
function Orders({ user }) {
if (!user) return <Login />;
const [page, setPage] = useState(1);
const orders = useOrders(user.id, page);
return <OrderList orders={orders} />;
}
So the early return comes before the hooks. When user is null, zero hooks are called. When the user logs in, suddenly two hooks are called. React sees the hook count change between renders and throws "Rendered more hooks than during the previous render".
It's easy to miss, because it looks like a normal guard clause, and it only breaks when user changes from null to a value while the component stays mounted.
There are two fixes:
// 1. call hooks first, return after
function Orders({ user }) {
const [page, setPage] = useState(1);
const orders = useOrders(user?.id, page); // hook handles undefined id
if (!user) return <Login />;
return <OrderList orders={orders} />;
}
// 2. split: the guard lives in the parent
function OrdersPage({ user }) {
return user ? <Orders user={user} /> : <Login />;
}
I usually prefer the second. The inner component can assume a user exists, so I don't need user?.id everywhere, and switching between the two branches is a clean mount and unmount.
IntermediateI need state for each item in a list. Can I call useState in a loop?
So no, because the number of items can change between renders, which changes the number of hook calls. There are two good ways to handle it.
// Bad: hook count depends on items.length
function List({ items }) {
return items.map(item => {
const [open, setOpen] = useState(false);
return <Row item={item} open={open} />;
});
}
// Good: 1. extract a component: each instance has its own hooks
function Row({ item }) {
const [open, setOpen] = useState(false);
return (
<li onClick={() => setOpen(o => !o)}>
{item.name} {open && <Details item={item} />}
</li>
);
}
// Good: 2. one piece of state keyed by id
const [openIds, setOpenIds] = useState(() => new Set());
The first is my default. Each Row is its own component instance with its own slot list, so the rules are satisfied naturally, and the key on each row makes sure state follows the right item when the list reorders.
The second is better when the parent needs to know about all of them, like "collapse all" or "only one open at a time". Then the state belongs in the parent anyway.
Interviewers look for: "extract a component" as the reflex answer. It's also a hint about where the state should live.
IntermediateHow do you run an effect or fetch only under a condition, then?
So I always call the hook, and move the condition inside it. The hook call is unconditional; what the hook does is conditional.
// effect: condition inside
useEffect(() => {
if (!isLoggedIn) return;
const unsub = subscribeToNotifications(userId, onNotify);
return unsub;
}, [isLoggedIn, userId]);
// data library: use its built-in switch
const { data } = useQuery({
queryKey: ["profile", userId],
queryFn: () => getProfile(userId),
enabled: Boolean(userId), // hook always called, fetch is conditional
});
What happens in the effect is that it runs every time the deps change, but it bails out early when logged out. And because the deps include isLoggedIn, when the user logs out, the previous cleanup runs and unsubscribes. So it stays correct in both directions.
Same idea for custom hooks I write: I accept a possibly-undefined input, or an enabled flag, and handle it inside, rather than asking callers to call my hook conditionally.
So in practice, whenever I feel the urge to wrap a hook in an if, I either push the if inside or split the component.
AdvancedAre there any hooks you can call conditionally?
So yes, one: use, added in React 19. It reads a promise or a context, and unlike other hooks it can be called inside conditions and loops. It still has to be called from a component or hook, not from a regular function or inside a try/catch block.
import { use } from "react";
function Comments({ commentsPromise, showAuthor }) {
const comments = use(commentsPromise); // suspends until resolved
if (showAuthor) {
const theme = use(ThemeContext); // Good: allowed conditionally
// ...
}
return comments.map(c => <p key={c.id}>{c.text}</p>);
}
It works because use doesn't store anything in the component's hook slot list. For context, it just walks up the tree to the nearest provider. For a promise, it either returns the resolved value or suspends and lets the nearest Suspense show a fallback.
One gotcha with promises: the promise must be created outside the render, typically in a Server Component or a cache, and passed down. If I create it inside the component, like use(fetch(url)), every render creates a new promise and it suspends forever.
In Next.js App Router, the common pattern is a Server Component starting the fetch without awaiting it, passing the promise to a Client Component, and the client calling use on it inside a Suspense boundary.
Interviewers look for: knowing use is the exception, why it's allowed, and the "don't create the promise during render" gotcha.
19. Data Fetching
BasicHow do you fetch data in a React component, and what states do you handle?
So in plain React, without a library, I fetch inside a useEffect and I always model three states: loading, error and success. If I only handle the happy path, the user sees a blank screen or a crash the first time the network is slow.
Let me give you an example.
function User({ id }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const controller = new AbortController();
setLoading(true);
setError(null);
fetch(`/api/users/${id}`, { signal: controller.signal })
.then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); })
.then(setUser)
.catch(e => { if (e.name !== "AbortError") setError(e); })
.finally(() => setLoading(false));
return () => controller.abort();
}, [id]);
if (loading) return <Spinner />;
if (error) return <ErrorMessage error={error} />;
return <Profile user={user} />;
}
What happens here is: every time id changes, I reset loading and error, start a new request, and the cleanup aborts the previous one. I check r.ok because fetch doesn't reject on a 404 or 500. And I ignore AbortError because that's me cancelling, not a real failure.
So in practice I'd write this once to show I understand it, but in a real app I'd reach for TanStack Query or let the framework fetch on the server.
Interviewers look for: the three states, the r.ok check, and a cleanup. Missing the cleanup is the most common gap.
BasicDoes fetch throw when the server returns a 404 or 500?
No, and that's a gotcha I've seen in a lot of code reviews. fetch only rejects when the request can't complete at all: the network is down, DNS fails, CORS blocks it, or it gets aborted. A 404 or a 500 is still a successful HTTP round trip, so the promise resolves.
Let me give you an example.
// Bug: a 500 with an HTML error page lands in .json() and throws a confusing SyntaxError
const data = await fetch("/api/orders").then(r => r.json());
// Fix: check the status myself
const res = await fetch("/api/orders");
if (!res.ok) throw new Error(`Orders failed: ${res.status}`);
const data = await res.json();
res.ok is true only for status 200 to 299. In my last project we wrapped this in a small apiFetch helper that checks ok, parses JSON, and throws a typed error with the status and response body. That way every caller gets consistent errors and the error UI can show "not found" differently from "server error".
So in practice I never call raw fetch from components. It always goes through one helper.
IntermediateWhat is a race condition in data fetching, and how do you prevent it?
So a race condition is when two requests are in flight and the older one finishes last, overwriting the newer data. The UI then shows data that doesn't match what the user picked.
Let me give you a scenario. A user clicks user 1, then quickly user 2. Request 1 is slow, request 2 is fast. Request 2 lands, then request 1 lands and calls setUser. Now the header says "User 2" but the profile shows user 1.
There are two fixes I use. The first is aborting, which also saves bandwidth:
useEffect(() => {
const controller = new AbortController();
fetch(`/api/users/${id}`, { signal: controller.signal })
.then(r => r.json())
.then(setUser)
.catch(e => { if (e.name !== "AbortError") setError(e); });
return () => controller.abort();
}, [id]);
The second is an ignore flag, for when the async work isn't abortable, like a third-party SDK call:
useEffect(() => {
let ignore = false;
sdk.getUser(id).then(u => { if (!ignore) setUser(u); });
return () => { ignore = true; };
}, [id]);
Both work because React runs the cleanup of the previous effect before running the next one. So the stale request is either cancelled or its result is dropped. Libraries like TanStack Query solve this for you because results are keyed by the query key.
Interviewers look for: a concrete "fast then slow" scenario, and knowing that the effect cleanup is what makes the fix work.
IntermediateWhy would you use TanStack Query instead of useEffect and useState?
So the way I think about it is that server data isn't really state I own. It's a cached copy of something that lives on the server and can change without telling me. useState has no idea about caching or staleness, so I end up hand-writing all of that.
Let me give you an example.
const { data, isPending, error } = useQuery({
queryKey: ["user", id],
queryFn: () => apiFetch(`/api/users/${id}`),
staleTime: 30_000,
});
With those few lines I get caching by key, so going back to a page is instant. I get deduping, so if the header and the sidebar both ask for ["user", id], there's one request. I get retries, refetch on window focus, and race-condition safety for free. And for writes, useMutation plus invalidateQueries refreshes everything that depends on that data.
In my last project we had a dashboard with about fifteen widgets, and several showed the same account data. Before the migration we had duplicate requests and widgets disagreeing with each other after an edit. After moving to TanStack Query, invalidating one key updated all of them.
So in practice: client-side server data goes in TanStack Query or SWR, UI state stays in useState, and in Next.js I often fetch in server components instead and skip the client cache entirely.
IntermediateIn TanStack Query, what's the difference between staleTime and gcTime?
So staleTime is how long cached data counts as fresh, and gcTime is how long unused data stays in memory before it's thrown away. They answer different questions: "should I refetch?" versus "do I still have anything to show?"
staleTime | gcTime | |
|---|---|---|
| Default | 0 (stale immediately) | 5 minutes |
| Controls | Whether a mount or focus triggers a background refetch | When an inactive query is removed from the cache |
| Effect | Higher means fewer requests | Higher means instant back-navigation for longer |
Let me give you a scenario. With the defaults, I open a user page, leave, and come back two minutes later. The data is still in the cache because gcTime hasn't run out, so it shows instantly. But it's stale, so a background refetch fires and the page updates if anything changed.
For something like a list of countries I'd set staleTime: Infinity. For a live order status I'd leave it at 0 and maybe add refetchInterval. One gotcha: in v5, cacheTime was renamed to gcTime, and isLoading now means "pending and fetching for the first time". The general "no data yet" flag is isPending.
Interviewers look for: fresh versus stale versus garbage-collected as three separate ideas, and picking staleTime per data type.
AdvancedWhat is a request waterfall, and how do you avoid one?
So a waterfall is when requests that could run in parallel run one after another, because each one only starts after the previous component rendered. The total wait becomes the sum of all requests instead of the longest one.
Let me give you an example.
// Waterfall: Posts can't start fetching until Profile has finished
function Profile({ id }) {
const { data: user } = useQuery({ queryKey: ["user", id], queryFn: () => getUser(id) });
if (!user) return <Spinner />;
return <><h1>{user.name}</h1><Posts userId={id} /></>;
}
// Parallel: start both at the same level
function Profile({ id }) {
const user = useQuery({ queryKey: ["user", id], queryFn: () => getUser(id) });
const posts = useQuery({ queryKey: ["posts", id], queryFn: () => getPosts(id) });
// render when ready
}
What happens in the first version is that Posts doesn't even mount until the user arrives, so its request starts late. If each takes 300 ms, that's 600 ms instead of 300.
On the server, in a Next.js server component, the same bug shows up as two awaits in a row. There I use Promise.all, or I start the promise in the page and pass it down to be unwrapped with use() inside a Suspense boundary, so slow parts stream in later.
The only real waterfalls I accept are true dependencies, like needing the user's org id before fetching the org. Even then I ask whether the backend can return both in one call.
Interviewers look for: recognising that component nesting creates waterfalls, and knowing the fixes: hoist, parallelise, prefetch, or move the fetch to the server.
AdvancedHow does data fetching with Suspense and the use hook work in React 19?
So with Suspense, a component doesn't manage its own loading flag. It reads the data, and if the data isn't ready it suspends. The nearest <Suspense> boundary above it shows the fallback until it resolves. In React 19, use(promise) is the official way to read a promise like that.
Let me give you an example in Next.js.
// Server component: start the request, don't await it
export default function Page({ params }) {
const commentsPromise = getComments(params.id);
return (
<Suspense fallback={<CommentsSkeleton />}>
<Comments commentsPromise={commentsPromise} />
</Suspense>
);
}
// Client component
"use client";
import { use } from "react";
export function Comments({ commentsPromise }) {
const comments = use(commentsPromise);
return comments.map(c => <p key={c.id}>{c.text}</p>);
}
What happens here is the page shell renders and streams immediately, and the comments stream in when the promise resolves. Errors go to the nearest error boundary rather than an error variable.
One gotcha I've hit: you can't create the promise inside the client component's render, like use(fetch(...)). Every render would make a new promise, so it would suspend forever. The promise has to be stable: created on the server, cached, or coming from a library like TanStack Query's useSuspenseQuery.
So in practice, Suspense moves loading UI out of the component and into the layout, which is nicer to design around.
20. Performance
BasicA React page feels slow. How do you find out why?
So my first rule is to measure before changing anything. Most of the time the slow part isn't where people guess, and adding useMemo everywhere just adds complexity.
There are really three things I check:
- Is it rendering or loading? The browser Performance panel and Lighthouse tell me whether it's a big bundle, slow network requests, or long tasks on the main thread.
- If it's rendering, which components? I open the React DevTools Profiler, hit record, do the slow interaction, and look at the flame graph. It shows what rendered, how long each took, and with "why did this render" turned on, the reason.
- Is it a real user problem? I test with CPU throttling, because my laptop hides a lot.
Let me give you an example. In my last project, typing in a search box on a reports page lagged. The Profiler showed every keystroke re-rendering a 2,000-row table, because the search text lived in the page component. Moving the input's state into its own component and deferring the filter fixed it. No memoisation needed.
So in practice: profile, fix the one biggest thing, profile again.
Interviewers look for: "measure first" and naming the React DevTools Profiler. Jumping straight to React.memo is a red flag.
BasicWhat does React.memo do, and when doesn't it help?
So React.memo wraps a component and tells React: when the parent re-renders, compare my new props with my old ones, and if they're all the same, skip rendering me. The comparison is shallow, each prop with Object.is.
Let me give you an example of where it silently does nothing.
const Row = React.memo(function Row({ item, onSelect }) {
return <li onClick={() => onSelect(item.id)}>{item.name}</li>;
});
function List({ items }) {
const [selected, setSelected] = useState(null);
return items.map(item => (
<Row key={item.id} item={item} onSelect={id => setSelected(id)} />
));
}
What happens here is that every render of List creates a brand-new arrow function for onSelect. A new function is never equal to the old one, so every Row re-renders anyway. The memo check just costs extra work. Here I'd pass setSelected directly, since state setters are stable, or wrap the handler in useCallback.
It also doesn't help when the component is cheap to render, or when its props genuinely change every time. And it doesn't stop re-renders caused by the component's own state or a context it reads.
So in practice I use memo on components the Profiler shows are expensive and re-rendering with the same data. With the React Compiler turned on, I mostly don't write it by hand at all.
IntermediateHow can you avoid re-renders without using memo at all?
So my preferred fix is structural: change where state lives so the expensive part isn't inside the component that re-renders. There's nothing to keep in sync afterwards, unlike memoisation.
There are two moves I use. The first is moving state down:
// Before: every keystroke re-renders Chart
function Page() {
const [text, setText] = useState("");
return <><input value={text} onChange={e => setText(e.target.value)} /><Chart /></>;
}
// After: the state lives only where it's used
function SearchBox() {
const [text, setText] = useState("");
return <input value={text} onChange={e => setText(e.target.value)} />;
}
function Page() { return <><SearchBox /><Chart /></>; }
The second is lifting content up, by passing it as children:
function ScrollTracker({ children }) {
const [y, setY] = useState(0);
// ...updates y on scroll
return <div><ProgressBar y={y} />{children}</div>;
}
<ScrollTracker><Article /></ScrollTracker>
What happens in the second one is that the <Article /> element was created by the parent of ScrollTracker, not by ScrollTracker itself. When y changes, ScrollTracker re-renders but children is the same element object as last time, so React skips it.
So in practice I reach for these before memo. They make the code simpler instead of more complex.
Interviewers look for: explaining why the children trick works: the element was created higher up, so its reference doesn't change.
IntermediateWhat is code splitting, and how do you do it in React?
So code splitting means breaking the JavaScript bundle into smaller files, so the user downloads only what the current screen needs. In React the tools are lazy with a dynamic import(), and Suspense for the loading state.
Let me give you an example.
import { lazy, Suspense } from "react";
const ReportEditor = lazy(() => import("./ReportEditor"));
function ReportPage({ editing }) {
return editing ? (
<Suspense fallback={<EditorSkeleton />}>
<ReportEditor />
</Suspense>
) : <ReportView />;
}
What happens here is the bundler sees the dynamic import() and puts ReportEditor and its dependencies in a separate chunk. That chunk is only requested the first time editing is true. Meanwhile Suspense shows the skeleton.
In my last project, the rich-text editor library alone was around 200 KB. Most users only read reports, so splitting it out cut the main bundle noticeably. I also prefetched it on hover of the "Edit" button, so the delay was basically invisible.
I split at route boundaries first, then at heavy, rarely used components like charts, maps, editors and modals. Next.js already splits per route automatically, and there I use next/dynamic for component-level splits. One gotcha: lazy must be called at module level, not inside a component, or it creates a new component every render.
IntermediateTyping in a search input lags because filtering a big list is slow. How do you fix it?
So the problem is that the input update and the expensive filter happen in the same render, so the input can't paint until the list has finished. My fix is to split them into an urgent update and a non-urgent one, using useDeferredValue or useTransition.
Let me give you an example.
function Search({ items }) {
const [query, setQuery] = useState("");
const deferredQuery = useDeferredValue(query);
const isStale = query !== deferredQuery;
return (
<>
<input value={query} onChange={e => setQuery(e.target.value)} />
<div className={isStale ? "dimmed" : ""}>
<Results items={items} query={deferredQuery} />
</div>
</>
);
}
const Results = memo(function Results({ items, query }) {
const filtered = items.filter(i => i.name.includes(query));
return filtered.map(i => <Row key={i.id} item={i} />);
});
What happens here is the input renders immediately with the new query. React then renders Results with deferredQuery in the background, and if the user types again it throws that work away and starts over. The memo matters: without it, Results would still re-render in the urgent pass.
I use useTransition instead when I own the state update, like switching tabs: startTransition(() => setTab(next)). I'd pick this over a debounce because it adapts to the device: fast machines update almost instantly, slow ones don't freeze.
And if the list is thousands of rows, I'd also virtualise it, because deferring doesn't make rendering 10,000 DOM nodes cheaper.
Interviewers look for: the idea of urgent versus non-urgent updates, and why this is different from debouncing.
AdvancedYou need to render a list of 10,000 rows. What do you do?
So I'd virtualise it. Rendering 10,000 rows means 10,000 sets of DOM nodes, which is slow to create, slow to lay out and heavy on memory. With virtualisation I only render the rows in the viewport plus a small buffer, around 20 to 40, and swap which ones they are as the user scrolls.
Let me give you an example with TanStack Virtual.
function BigList({ rows }) {
const parentRef = useRef(null);
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 40,
overscan: 8,
});
return (
<div ref={parentRef} className="scroller">
<div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
{virtualizer.getVirtualItems().map(v => (
<Row key={rows[v.index].id} row={rows[v.index]} offset={v.start} />
))}
</div>
</div>
);
}
What happens here is the inner container is given the full height, so the scrollbar looks right, and each visible row is absolutely positioned at its offset.
A few trade-offs I've run into. Ctrl+F in the browser can't find rows that aren't rendered. Variable-height rows need measuring, which TanStack Virtual supports with measureElement. Screen readers need correct aria-rowcount and aria-rowindex. And sticky headers or grouped rows take extra work.
So before virtualising I also ask the product question: does the user really need 10,000 rows at once, or would pagination or server-side filtering be better? Often it's both: paginate from the server, virtualise what's loaded.
AdvancedWhat is the React Compiler, and does it mean you stop using useMemo and useCallback?
So the React Compiler is a build-time tool, stable since late 2025, that analyses components and hooks and inserts memoisation automatically. It caches JSX, computed values and callbacks based on what they actually depend on, which is often more fine-grained than what I'd write by hand.
Let me give you an example.
// What I write
function Cart({ items, onCheckout }) {
const total = items.reduce((s, i) => s + i.price * i.qty, 0);
return <Summary total={total} onCheckout={() => onCheckout(items)} />;
}
// With the compiler, total and the arrow function are only recomputed
// when items or onCheckout change, so a memoised Summary can skip.
For new code with the compiler turned on, I mostly stop writing useMemo, useCallback and React.memo by hand. The code is cleaner and reviews stop being arguments about dependency arrays.
But it doesn't make them useless. I still keep a manual useMemo where a value is an effect dependency and I need precise control over when the effect fires. And the compiler relies on my code following the rules of React: pure render, no mutating props or state. If a component breaks those rules, it skips optimising it, and the ESLint plugin flags it.
So in practice: turn it on, fix the lint errors it surfaces, keep profiling. It removes a whole category of wasted re-renders, but it doesn't fix big bundles, waterfalls or 10,000-row lists.
Interviewers look for: knowing it's a compile step that automates memoisation, and that it depends on components being pure.
21. Patterns Worth Knowing
BasicWhat is the container and presentational pattern? Is it still relevant with hooks?
So the idea is to split a component in two. The container knows where data comes from: it fetches, holds state and handles actions. The presentational component only takes props and renders them, with no idea where they came from.
Let me give you an example.
// Presentational: easy to test, easy to put in Storybook
function OrderList({ orders, onCancel }) {
return <ul>{orders.map(o => (
<li key={o.id}>{o.total} <button onClick={() => onCancel(o.id)}>Cancel</button></li>
))}</ul>;
}
// Container: wires it to data
function OrdersContainer() {
const { data = [] } = useQuery({ queryKey: ["orders"], queryFn: getOrders });
const cancel = useCancelOrder();
return <OrderList orders={data} onCancel={cancel.mutate} />;
}
With hooks, the strict version of the pattern matters less, because a custom hook like useOrders() already pulls the data logic out of the component. Dan Abramov, who popularised the pattern, said himself he no longer pushes it dogmatically.
But the underlying idea is still very relevant. In Next.js it basically maps onto server and client components: the server component fetches, and the client component just renders and handles clicks. And in a design system, I want components that are purely presentational so they work in any app.
So in practice I don't create two files for every component, but I do keep data fetching out of reusable UI components.
IntermediateWhat are compound components, and how would you build a Tabs component with them?
So compound components are a set of components that work together and share hidden state, like <select> and <option>. The user arranges the pieces freely, and the parent coordinates them through context, so there's no prop wiring.
Let me give you an example.
const TabsContext = createContext(null);
function Tabs({ defaultValue, children }) {
const [value, setValue] = useState(defaultValue);
return <TabsContext.Provider value={{ value, setValue }}>{children}</TabsContext.Provider>;
}
function useTabs() {
const ctx = useContext(TabsContext);
if (!ctx) throw new Error("Tabs.* must be used inside <Tabs>");
return ctx;
}
Tabs.Trigger = function Trigger({ value, children }) {
const { value: active, setValue } = useTabs();
return <button role="tab" aria-selected={active === value} onClick={() => setValue(value)}>{children}</button>;
};
Tabs.Panel = function Panel({ value, children }) {
const { value: active } = useTabs();
return active === value ? <div role="tabpanel">{children}</div> : null;
};
The reason I'd pick this over one big <Tabs items={[...]} /> with a config array is flexibility. The consumer can put an icon in a trigger, wrap the list in a toolbar, or put panels anywhere, and I don't need a new prop for each case.
The useTabs guard gives a clear error if someone uses a piece outside the parent. Radix UI and shadcn/ui are built exactly like this, so it's the pattern most design systems follow now.
Interviewers look for: context as the glue, and the API design argument: flexible composition instead of an ever-growing list of props.
IntermediateWhat does an error boundary catch, and what doesn't it catch?
So an error boundary catches errors thrown while React is rendering its children, in lifecycle methods, and in constructors below it. Instead of the whole app unmounting to a blank page, it shows a fallback for just that part of the tree.
Let me give you an example.
class ErrorBoundary extends React.Component {
state = { error: null };
static getDerivedStateFromError(error) { return { error }; }
componentDidCatch(error, info) { reportError(error, info.componentStack); }
render() {
return this.state.error ? this.props.fallback : this.props.children;
}
}
<ErrorBoundary fallback={<p>Chart failed to load.</p>}>
<RevenueChart />
</ErrorBoundary>
What it doesn't catch: errors in event handlers, errors in plain async code like a setTimeout or a promise I didn't hand to React, errors during server rendering, and errors thrown by the boundary itself. For event handlers I use try/catch and set error state. If I want an async error to reach the boundary, I can set state and throw it during the next render. In React 19, errors in actions run through startTransition or a form action do reach the boundary.
It still has to be a class, since there's no hook for it. In real projects I use the react-error-boundary package, which gives a function-friendly API with resetKeys and a retry button. I place boundaries around independent widgets, so one broken chart doesn't take down the dashboard.
IntermediateWhat is a portal, and how do events behave inside one?
So a portal lets me render a component into a different DOM node, usually document.body, while it stays in the same place in the React tree. I use it for modals, tooltips, dropdowns and toasts, anything that has to escape a parent's overflow: hidden or stacking context.
Let me give you an example.
function Modal({ children, onClose }) {
return createPortal(
<div className="backdrop" onClick={onClose}>
<div className="dialog" onClick={e => e.stopPropagation()}>{children}</div>
</div>,
document.body
);
}
function Card() {
return (
<div className="card" onClick={() => console.log("card clicked")}>
<Modal onClose={...}><button>OK</button></Modal>
</div>
);
}
The gotcha is event bubbling. In the DOM, the modal is a child of body, not of the card. But React events bubble through the React tree. So clicking "OK" does log "card clicked", because Modal is a React child of .card. Context works the same way: the modal can still read providers from above Card.
I've hit this bug for real: clicking inside a modal triggered a row's click handler underneath. The fix was stopping propagation at the dialog, or not nesting the modal inside the clickable element.
For accessibility I still need focus trapping, Escape to close and aria-modal. These days I often use the native <dialog> element with showModal(), which puts it in the top layer without a portal at all.
Interviewers look for: events bubbling through the React tree rather than the DOM tree. That's the part most people don't know.
AdvancedCompare higher-order components, render props and custom hooks for sharing logic.
So all three solve the same problem: reusing stateful logic across components. HOCs and render props were the answers before hooks. Custom hooks replaced both for almost everything, because they share logic without changing the component tree.
Let me give you the same logic three ways.
// HOC: wraps the component and injects props
const WithMouse = withMouse(Chart); // Chart gets props.mouse
// Render prop: a component that calls a function child
<Mouse>{mouse => <Chart mouse={mouse} />}</Mouse>
// Custom hook: just a function call
function Chart() {
const mouse = useMouse();
...
}
| HOC | Render prop | Custom hook | |
|---|---|---|---|
| Where props come from | Hidden, easy to clash | Explicit | Explicit |
| Extra tree nesting | Yes, a wrapper per HOC | Yes, nested callbacks | None |
| Composing several | Wrapper hell | Pyramid of callbacks | Just call them in order |
The problem with HOCs is that when three of them each inject props, it's hard to tell where data came from, and two can overwrite each other's props. Render props fix that but nest badly.
I'd still use a render prop when the shared thing is about rendering, like a virtualised list asking me how to render each row, or a headless component letting me control markup. And HOCs still show up at boundaries, like wrapping a page with an auth check. But for logic, it's a custom hook every time.
AdvancedHow would you design a component that can be both controlled and uncontrolled?
So the convention, same as native inputs, is: if the parent passes value, the component is controlled and just reports changes through onChange. If it only passes defaultValue, the component keeps its own state. Good library components support both.
Let me give you an example with a small hook.
function useControllableState({ value, defaultValue, onChange }) {
const [internal, setInternal] = useState(defaultValue);
const isControlled = value !== undefined;
const current = isControlled ? value : internal;
const setValue = next => {
if (!isControlled) setInternal(next);
onChange?.(next);
};
return [current, setValue];
}
function Toggle({ pressed, defaultPressed = false, onPressedChange }) {
const [on, setOn] = useControllableState({
value: pressed, defaultValue: defaultPressed, onChange: onPressedChange,
});
return <button aria-pressed={on} onClick={() => setOn(!on)}>{on ? "On" : "Off"}</button>;
}
What happens here: <Toggle /> just works on its own. <Toggle pressed={x} onPressedChange={setX} /> lets a parent own it, for example to reset it from a "clear filters" button.
The gotcha is switching modes. If value goes from a string to undefined, the component suddenly flips to uncontrolled with a stale internal value. React warns about exactly this on native inputs. So I document that you pick one mode for the component's lifetime, and in dev I log a warning if it changes.
Interviewers look for: the value versus defaultValue convention and awareness of the mode-switching bug.
22. Common Mistakes
BasicWhat's wrong with <button onClick={handleClick()}>?
So the parentheses call the function immediately, during render, and pass its return value as the click handler. Usually that return value is undefined, so the button does nothing when clicked, and the function runs on every render instead.
Let me give you an example where it gets worse.
function Counter() {
const [n, setN] = useState(0);
const increment = () => setN(n + 1);
// Bug: calls increment during render, which sets state, which re-renders...
return <button onClick={increment()}>{n}</button>;
}
// React error: "Too many re-renders. React limits the number of renders
// to prevent an infinite loop."
What happens here is that increment() sets state while rendering, which schedules another render, which calls it again. React bails out with the "Too many re-renders" error.
The fix is to pass the function itself, or wrap it when I need an argument:
<button onClick={increment}>+</button>
<button onClick={() => remove(item.id)}>Delete</button>
So whenever I see "Too many re-renders", the first thing I look for is a handler that's being called instead of passed.
BasicWhy does {items.length && <List />} sometimes show a "0" on the page?
So && in JavaScript doesn't return a boolean. It returns the left side if it's falsy, otherwise the right side. When the length is 0, the expression evaluates to the number 0, and React renders numbers as text. false, null and undefined render nothing, but 0 does.
Let me give you an example.
0 && "x"; // 0
[].length && "x"; // 0
// Bug: an empty cart shows a lonely "0"
{cart.length && <CartItems items={cart} />}
// Fixes: make the condition a real boolean
{cart.length > 0 && <CartItems items={cart} />}
{cart.length ? <CartItems items={cart} /> : null}
I've actually shipped this one. A notification badge showed "0" in the corner for users with no notifications, and it passed review because we tested with seed data that always had some. In React Native it's worse: rendering a bare number outside a <Text> crashes the app.
So in practice I always put an explicit comparison or Boolean() on the left of && when the value could be a number.
Interviewers look for: knowing that && returns an operand, not a boolean, and that React renders 0 but not false.
IntermediateI push an item into a state array and the screen doesn't update. Why?
So React decides whether to re-render by comparing the old and new state with Object.is. If I mutate the array with push and then set the same array back, it's the same reference, so React sees no change and bails out.
Let me give you an example.
const [todos, setTodos] = useState([]);
// Bug: same array reference, React skips the update
function add(text) {
todos.push({ id: crypto.randomUUID(), text });
setTodos(todos);
}
// Fix: create a new array
function add(text) {
setTodos(prev => [...prev, { id: crypto.randomUUID(), text }]);
}
// Same rule for nested objects
setUser(prev => ({ ...prev, address: { ...prev.address, city: "Pune" } }));
What's sneaky is that mutation sometimes looks like it works, because some other state change triggers a render and the mutated array happens to be displayed. Then a memo'd child or a useEffect depending on todos doesn't update, because to them the reference never changed.
For the non-mutating versions of common operations I use map to update, filter to remove, and the ES2023 methods toSorted, toReversed and with instead of sort and reverse. For deeply nested state, Immer lets me write mutating code that produces new objects.
IntermediateAn input loses focus after every keystroke. What's likely going on?
So nine times out of ten, a component is being defined inside another component. Every render of the parent creates a brand-new component function, React sees a different type at that position, and it unmounts the old one and mounts a fresh one. The input is a new DOM node, so focus and any local state are gone.
Let me give you an example.
function SignupForm() {
const [email, setEmail] = useState("");
// Bug: a new Field type is created on every render
function Field(props) {
return <input {...props} />;
}
return <Field value={email} onChange={e => setEmail(e.target.value)} />;
}
What happens here is: I type a letter, setEmail re-renders SignupForm, Field is now a different function from last time, so reconciliation says "different type, throw it away". The fix is to move Field to module level so its identity is stable.
The other common cause is an unstable key, like key={Math.random()} or a key built from the value being edited. That also forces a remount on every render.
So when I see lost focus or state resetting unexpectedly, I check two things: is a component defined inside render, and is a key changing.
Interviewers look for: connecting the symptom to reconciliation: a different component type or key means unmount and remount.
IntermediateWhy does this effect run in an infinite loop?
function Results({ query, page }) {
const [data, setData] = useState(null);
const params = { query, page };
useEffect(() => {
search(params).then(setData);
}, [params]);
}
So params is a new object on every render. React compares dependencies with Object.is, and two different objects are never equal, even with the same contents. So the effect runs after every render, calls setData, which renders again, which creates a new params, and it never stops.
The fix is to depend on the primitive values instead, and build the object inside the effect:
useEffect(() => {
const params = { query, page };
search(params).then(setData);
}, [query, page]);
Strings and numbers compare by value, so the effect only runs when the query or page really changes. If the object comes from a parent as a prop, I'd ask the parent to memoise it, or depend on its individual fields.
The other classic loop is an effect that updates its own dependency, like useEffect(() => setCount(count + 1), [count]). There the question is usually whether this should be an effect at all. Most of the time the value can be computed during render or set in the event handler that caused the change.
I rely on the react-hooks/exhaustive-deps lint rule, which also warns when a dependency is an object that changes every render.
AdvancedWhat's wrong with copying props into state, and how do you reset a component when a prop changes?
So copying a prop into state creates two sources of truth. useState(initial) only reads its argument on the first render, so when the prop changes later, the state keeps the old value and the two drift apart.
Let me give you an example.
// Bug: switching to a different contact keeps the previous contact's draft
function EditContact({ contact }) {
const [name, setName] = useState(contact.name);
return <input value={name} onChange={e => setName(e.target.value)} />;
}
// Common but clumsy fix: sync with an effect (renders once with stale data first)
useEffect(() => { setName(contact.name); }, [contact.id]);
// Better: reset the whole component by giving it a key
<EditContact key={contact.id} contact={contact} />
What happens with the key version is that a new contact.id means a different key, so React unmounts the old editor and mounts a fresh one. All its state initialises from the new props. No effect, no flash of stale data.
The related mistake is storing derived data in state, like keeping filteredItems in state next to items and filter. I just compute it during render: const filtered = items.filter(...). If it's expensive, useMemo.
So my rule is: state is only for things that can't be computed from props or other state. And if I need to reset state when an identity changes, I reach for key before an effect.
Interviewers look for: using key to reset state. It's one of the clearest signals that someone really understands reconciliation.
23. Why Next.js Exists
BasicWhat is Next.js, and why would you use it instead of plain React?
So React on its own is a library for building UI. It doesn't decide routing, data fetching, server rendering or how you deploy. Next.js is a framework on top of React that makes those decisions for you, and the big one is rendering on the server.
Let me give you the contrast. With a plain client-side React app, the server sends an almost empty HTML file with <div id="root"></div>. The browser downloads the bundle, runs it, fetches data, and only then shows content. With Next.js, the server sends the finished HTML, so the user sees content straight away and crawlers can read it.
On top of that, what I get out of the box is:
- File-based routing with nested layouts in the
app/folder. - Server Components, so data fetching and heavy libraries stay on the server.
- A backend in the same project: route handlers and server actions.
- Caching and static generation, per route or even per component.
- Image, font and script optimisation, and automatic code splitting per route.
In my last project we moved a marketing site plus product catalogue from a Vite SPA to Next.js. The main win was SEO and first-load speed: product pages started showing proper previews when shared, and the largest contentful paint dropped a lot on mobile.
The React docs themselves now recommend starting new apps with a framework, and Create React App has been deprecated.
BasicWhat is hydration?
So hydration is the step where React, in the browser, takes HTML that the server already rendered and attaches event handlers and state to it, making it interactive. React doesn't rebuild the DOM. It walks the existing HTML, matches it against what the components would render, and wires it up.
Let me give you a scenario. A product page is server-rendered. The user sees the title, price and an "Add to cart" button almost immediately. But until the JavaScript has downloaded and hydration has run, clicking that button does nothing, because there's no click handler yet. That gap is the time to interactive.
// Client entry in a plain SSR setup
import { hydrateRoot } from "react-dom/client";
hydrateRoot(document.getElementById("root"), <App />);
// Next.js does this for you
The reason this matters in interviews is the trade-off. Server rendering improves how fast content appears, but the user still pays for downloading and hydrating the JavaScript. That's why React 18 added selective hydration with Suspense, so parts of the page can hydrate independently, and why Server Components matter: components that never ship JavaScript don't need hydrating at all.
Interviewers look for: "attaches to existing HTML, doesn't re-create it", and the distinction between content visible and page interactive.
IntermediateWhen would you not use Next.js?
So I wouldn't use it when its main benefits, server rendering, SEO and first-load speed for anonymous visitors, don't matter to the product. Then it's extra complexity: a server to run, caching rules to learn, and a server/client boundary to think about.
There are a few cases where I'd pick something else:
- An internal tool or admin dashboard behind login. No SEO, users load it once and keep it open. A Vite SPA with React Router and TanStack Query is simpler and can be hosted as static files.
- A mostly static content site with little interactivity. Astro ships almost no JavaScript by default and fits better.
- An app that must run fully offline or be embedded in another app, like a browser extension or an Electron shell, where there's no server.
- A team that wants to avoid a Node server and just deploy to a CDN. Next.js can do static export, but you lose server features.
In my last company, the customer-facing site was Next.js, but the internal ops console was a Vite SPA. Builds were seconds, there was no server to operate, and nobody needed to learn caching rules just to add a form.
So in practice I choose based on who loads the page and how: public and content-heavy points to Next.js, logged-in and app-like is often fine as an SPA. React Router v7 in framework mode is also a strong option if I want SSR without the App Router model.
IntermediateYou get a "hydration mismatch" error. What causes it and how do you fix it?
So a hydration mismatch means the HTML the server sent doesn't match what React renders on the first pass in the browser. React then can't attach cleanly, so it warns and may throw away that part of the DOM and re-render it on the client, which also costs performance.
Let me give you the common causes I've actually hit:
// 1. Values that differ between server and browser
<p>Generated at {new Date().toLocaleTimeString()}</p>
<p>{Math.random()}</p>
// 2. Branching on browser-only things during render
{typeof window !== "undefined" && <span>{localStorage.theme}</span>}
// 3. Invalid HTML nesting, which the browser "fixes" before React sees it
<p><div>Oops</div></p>
A fourth one is browser extensions injecting attributes into <html> or <body>, which isn't my bug at all.
The fixes depend on the cause. For browser-only values I render a neutral version first and fill it in after mount:
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
return mounted ? <ThemeLabel theme={localStorage.theme} /> : null;
For a timestamp that's allowed to differ, I use suppressHydrationWarning on that one element. For a whole widget that can't render on the server, I use next/dynamic with ssr: false from a client component. And for dates, formatting with a fixed time zone and locale on both sides removes the difference.
Interviewers look for: the cause, "server and client rendered different things", plus at least two concrete sources like time, randomness, window checks or invalid nesting.
AdvancedWhat does "server rendering" actually cost you, and how does Next.js reduce that cost?
So server rendering moves work from the user's device to my server. That improves first paint and SEO, but it introduces new costs: server compute on every dynamic request, time to first byte if the server is slow, and the hydration cost on the client, since the JavaScript still has to download and run.
Let me break down how Next.js tackles each one.
| Cost | How Next.js reduces it |
|---|---|
| Rendering on every request | Static prerendering and ISR, so most pages are built once and served from a CDN |
| Slow time to first byte | Streaming with Suspense and loading.tsx, so the shell is sent before slow data finishes |
| Big bundle to hydrate | Server Components ship no JavaScript, and "use client" only at the leaves |
| Whole page mixing static and dynamic | Partial prerendering, now part of Cache Components: a static shell with dynamic holes streamed in |
In my last project, a product page was fully dynamic because it showed a personalised "recently viewed" strip. Every request hit the server and TTFB was around 600 ms. We made the page static, moved the personalised strip into a Suspense boundary that streams in, and TTFB dropped to CDN speed while the strip arrived a moment later.
So in practice the question I ask per page isn't "SSR or not", it's "which parts need to be fresh per request, and can everything else be static?"
24. Rendering Strategies — the core idea
BasicWhat's the difference between SSG, SSR, ISR and CSR?
So all four answer one question: when is the HTML for this page built? At build time, on every request, periodically in the background, or in the browser.
| HTML built | Speed | Freshness | Good for | |
|---|---|---|---|---|
| SSG (static) | Once, at build | Fastest, served from a CDN | Stale until next deploy | Docs, blog, marketing |
| ISR | At build, then rebuilt in the background | CDN-fast | Fresh within N seconds | Product listings, news |
| SSR | On every request | Waits for the server | Always current, can be personal | Dashboards, search, carts |
| CSR | In the browser after JS loads | Slow first paint | Live | Charts, editors, private widgets |
Let me give you how it looks in the App Router:
fetch(url, { next: { revalidate: 3600 } }); // ISR: reuse for an hour
fetch(url, { cache: "no-store" }); // SSR: fresh per request
// CSR: a "use client" component fetching in the browser
The important point is that I don't choose one for the whole site. In an e-commerce app, the home page and product pages might be ISR, the cart is SSR, and the reviews carousel is a client component.
Interviewers look for: framing it as "when is the HTML built", and mixing strategies per page rather than per app.
IntermediateHow does ISR actually work? What does the first visitor after the time runs out see?
So ISR is stale-while-revalidate for whole pages. The page is served from cache, and once it's older than the revalidate time, the next request still gets the old page instantly, but it triggers a regeneration in the background. Requests after that get the new version.
Let me walk through a timeline with revalidate: 60.
// app/products/[id]/page.tsx
export const revalidate = 60;
// 0s build: page generated, cached
// 30s visitor A: gets cached page (fresh)
// 90s visitor B: gets cached page (stale), triggers background rebuild
// 91s rebuild finishes, cache updated
// 95s visitor C: gets the new page
So visitor B, the first one after it expires, sees the stale version. Nobody ever waits for the rebuild. That's the design trade-off: speed always, freshness eventually.
Two things I've relied on in production. If the rebuild throws, for example the API is down, Next.js keeps serving the last good page rather than an error. And pages not generated at build time, say a new product id, get rendered on first request and then cached, as long as dynamicParams isn't set to false.
The gotcha is that "60 seconds" isn't a guarantee that users see changes within 60 seconds, if traffic is low the page could be stale for much longer until someone visits. When content has to update immediately, I use on-demand revalidation instead.
IntermediateYou're building an e-commerce site. Which strategy would you use for each page?
So I'd decide per page by asking two things: is the content the same for every visitor, and how quickly must changes show up?
Let me go through it.
| Page | Strategy | Why |
|---|---|---|
| Home, category pages | ISR plus on-demand revalidation | Same for everyone, needs SEO, changes a few times a day |
| Product page | Static shell, dynamic stock and price | SEO-critical, but stock must be live |
| Search results | Dynamic (SSR) | Depends on searchParams, too many combinations to prebuild |
| Cart, checkout | Dynamic, reads cookies | Personal, must be exact |
| Account order history | Dynamic, or CSR with TanStack Query | Behind login, no SEO |
| About, terms, help | Static | Rarely changes |
The interesting one is the product page. In my last project we made it static and revalidated it by tag when the CMS or catalogue changed. The price and "only 3 left" badge were in a Suspense boundary that fetched fresh data, so the page loaded from the CDN but stock was never wrong.
The one mistake I'd avoid is reading cookies in the root layout, for example to show the user's name in the header. That makes every page dynamic. I'd put the user menu in its own component behind Suspense, or render it on the client.
Interviewers look for: reasoning per page, and awareness that one dynamic call high in the tree can opt the whole route out of static rendering.
IntermediateWhat makes a route dynamic in the App Router?
So by default Next.js tries to prerender every route at build time. A route becomes dynamic, meaning rendered per request, as soon as it uses something that only exists at request time.
The things that trigger it are:
import { cookies, headers } from "next/headers";
import { connection } from "next/server";
export default async function Page({ searchParams }) {
const sp = await searchParams; // reading search params
const token = (await cookies()).get("t"); // cookies()
const ua = (await headers()).get("user-agent"); // headers()
await connection(); // "wait for a real request"
const data = await fetch(url, { cache: "no-store" }); // explicit no-store
}
// or forced at the route level
export const dynamic = "force-dynamic";
One version detail I watch: since Next.js 15, cookies(), headers(), params and searchParams are async, so I have to await them. And plain fetch calls are no longer cached by default like they were in 14, so I say explicitly what I want cached.
The way I check is the build output. next build marks each route as static or dynamic. In my last project a "static" blog was showing as dynamic, and the cause was an analytics helper in the layout reading headers(). Moving it into a client component made the whole blog static again.
So in practice I keep request-specific reads as deep in the tree as I can, ideally inside a Suspense boundary, so the rest can still be static.
AdvancedHow does on-demand revalidation work, and when would you use it over time-based revalidation?
So on-demand revalidation means I tell Next.js "this data changed, throw away the cached version now", instead of waiting for a timer. I use it whenever I know exactly when data changes, like a CMS publish or an admin editing a product.
Let me give you an example. I tag the data when fetching it, then invalidate the tag from a webhook.
// Fetching: tag the cached data
const product = await fetch(`${API}/products/${id}`, {
next: { tags: [`product-${id}`, "products"] },
}).then(r => r.json());
// app/api/revalidate/route.ts: called by the CMS on publish
import { revalidateTag } from "next/cache";
export async function POST(req) {
if (req.headers.get("x-secret") !== process.env.REVALIDATE_SECRET) {
return new Response("Unauthorized", { status: 401 });
}
const { id } = await req.json();
revalidateTag(`product-${id}`, "max");
return Response.json({ revalidated: true });
}
What happens is every cached fetch and page that used that tag is marked stale, and the next request regenerates it. revalidatePath("/products") does the same thing by URL instead of by data, which is simpler but coarser.
I prefer tags because one product appears on its own page, its category page, search, and the home page. Invalidating the data tag updates all of them without me listing paths. In Next.js 16, revalidateTag takes a cache profile as a second argument, and inside a server action I can use updateTag so the user who made the edit sees it immediately.
So in practice: on-demand for data I control, with a long time-based revalidate as a safety net in case a webhook fails.
Interviewers look for: tags over paths, securing the webhook, and keeping a fallback time-based revalidate.
AdvancedWhat is Partial Prerendering, and how does it change the static versus dynamic choice?
So Partial Prerendering means a single route can be both static and dynamic. Next.js prerenders a static shell at build time, and the dynamic parts, wrapped in Suspense, are left as holes that stream in per request. In Next.js 16 this is part of the Cache Components model, turned on with cacheComponents: true.
Let me give you an example.
// next.config.ts: cacheComponents: true
export default function ProductPage({ params }) {
return (
<>
<ProductDetails params={params} /> {/* cached, in the static shell */}
<Suspense fallback={<CartSkeleton />}>
<CartBadge /> {/* reads cookies: dynamic hole */}
</Suspense>
</>
);
}
async function ProductDetails({ params }) {
"use cache";
const { id } = await params;
const product = await getProduct(id);
return <h1>{product.name}</h1>;
}
What happens on a request is the static shell, with the product details and the skeleton, is sent immediately from the edge. In the same response, the server renders CartBadge and streams its HTML in to replace the skeleton.
The reason this matters is that before, one cookies() call anywhere made the whole route dynamic. Now the unit of the decision is the Suspense boundary, not the route. With Cache Components the model also flips: things are dynamic unless I mark them with "use cache", which makes caching explicit instead of guessed.
So in practice I design pages as "what's the shell, what are the holes", which is a nicer way to think than picking SSG or SSR per page.
25. The App Router & File Structure
BasicHow does routing work in the Next.js App Router?
So routing is based on folders inside app/. Each folder is a URL segment, and a folder only becomes a visitable route when it has a page.tsx. Other special files in that folder, like layout, loading and error, wrap the page with shared UI.
Let me give you an example.
app/
layout.tsx → root layout, wraps everything (has <html> and <body>)
page.tsx → /
dashboard/
layout.tsx → sidebar for everything under /dashboard
page.tsx → /dashboard
settings/page.tsx → /dashboard/settings
_components/Chart.tsx → private folder, never a route
blog/[slug]/page.tsx → /blog/hello-world
(marketing)/pricing/page.tsx → /pricing (group name not in URL)
api/health/route.ts → GET /api/health
What I like about this is that non-route files can live next to the route, a Chart.tsx inside dashboard/ isn't publicly reachable because there's no page.tsx for it. I usually still put them in an underscore folder to make it obvious.
So in practice the folder tree is the sitemap, and I can read the app's structure just from the file explorer.
Interviewers look for: "only page.tsx makes it public", plus knowing the special files and route groups.
BasicWhat's the difference between layout.tsx and template.tsx?
So both wrap the pages below them. The difference is what happens on navigation. A layout is preserved: when I move between sibling pages, it doesn't remount, so its state, scroll position and DOM stay. A template gets a new key on every navigation, so it remounts and its state resets.
Let me give you an example.
// app/dashboard/layout.tsx
export default function DashboardLayout({ children }) {
return (
<div className="shell">
<Sidebar /> {/* collapsed state survives /dashboard to /dashboard/settings */}
<main>{children}</main>
</div>
);
}
// app/dashboard/template.tsx
export default function Template({ children }) {
return <FadeIn>{children}</FadeIn>; // replays on every navigation
}
I use layouts for almost everything: nav, sidebars, providers. I'd reach for a template only when I specifically want per-page reset behaviour, like an enter animation, a useEffect that logs a page view on every navigation, or a feedback form that should clear between pages.
One gotcha with layouts: because they don't re-render on navigation, they don't receive the current pathname or searchParams. If the nav needs to highlight the active link, that small piece is a client component using usePathname().
IntermediateHow do loading.tsx and error.tsx work under the hood?
So they're just conventions for two React features. Next.js wraps the page in a <Suspense> boundary with loading.tsx as the fallback, and in an error boundary with error.tsx as the fallback. The nesting looks roughly like this:
<Layout>
<ErrorBoundary fallback={<Error />}>
<Suspense fallback={<Loading />}>
<Page />
</Suspense>
</ErrorBoundary>
</Layout>
So when the page is an async server component waiting on data, the loading UI shows instantly and the page streams in. When the page throws, the error UI replaces it while the layout stays.
// app/dashboard/error.tsx
"use client";
export default function Error({ error, reset }) {
return (
<div>
<p>Could not load the dashboard.</p>
<button onClick={() => reset()}>Try again</button>
</div>
);
}
It has to be a client component because error boundaries are a client-side React feature, and reset is a function that re-renders the segment.
The gotcha from that nesting is that error.tsx doesn't catch errors in the layout.tsx of the same folder, because the layout sits outside it. Those go to the parent segment's error.tsx, and errors in the root layout go to global-error.tsx. Also, in production the error message from a server component is replaced with a generic one plus a digest, so I don't leak internals, and I match the digest in server logs.
Interviewers look for: mapping the files to Suspense and error boundaries, and the "doesn't catch its own layout" gotcha.
IntermediateHow do dynamic segments work, and how do you prebuild them?
So a folder in square brackets, like [slug], matches any value in that part of the URL, and the page gets it through params. Since Next.js 15, params is a promise, so I await it.
Let me give you an example with static generation.
// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
const posts = await getAllPosts();
return posts.map(p => ({ slug: p.slug }));
}
export async function generateMetadata({ params }) {
const { slug } = await params;
const post = await getPost(slug);
return { title: post.title, description: post.excerpt };
}
export default async function Post({ params }) {
const { slug } = await params;
const post = await getPost(slug);
if (!post) notFound();
return <article><h1>{post.title}</h1></article>;
}
What happens at build: generateStaticParams returns the list of slugs, and Next.js prerenders a page for each. A slug not in the list is rendered on the first request and cached, unless I set export const dynamicParams = false, which makes unknown slugs a 404.
There are also catch-all segments: [...slug] matches /docs/a/b/c as an array, and [[...slug]] also matches /docs itself.
In my last project, we had 50,000 products. Prebuilding all of them made builds take forever, so we generated only the top 1,000 by traffic and let the rest render on demand. Same end result, much faster deploys.
IntermediateWhy use <Link> instead of <a>, and when would you use router.push?
So a plain <a> does a full page load: the browser throws away the page, re-downloads HTML and JavaScript, and all client state resets. <Link> does client-side navigation: it fetches only the RSC payload for the new route, keeps shared layouts mounted, and swaps just the part that changed.
It also prefetches. When a link scrolls into the viewport in production, Next.js fetches the new route in the background. For static routes that's the whole page; for dynamic ones it's the shared layout down to the nearest loading.tsx. So the click feels instant.
Let me give you an example of both.
import Link from "next/link";
<Link href={`/orders/${order.id}`}>View order</Link>
"use client";
import { useRouter } from "next/navigation";
function CheckoutForm() {
const router = useRouter();
async function onSubmit(data) {
const order = await placeOrder(data);
router.push(`/orders/${order.id}`);
}
}
I use <Link> for anything the user clicks to go somewhere, because it's a real anchor: it works with middle-click, "open in new tab", and screen readers. I use router.push only for navigation that follows logic, like after a form submits. router.replace when I don't want a history entry, and router.refresh() to re-fetch server components for the current route without losing client state.
One gotcha: useRouter comes from next/navigation in the App Router, not next/router, which is the Pages Router version.
AdvancedHow would you build a modal that has its own URL, like a photo feed where clicking opens a modal but refreshing shows a full page?
So this is the pattern parallel routes and intercepting routes were made for. A parallel route gives the layout a second slot to render into, and an intercepting route catches a navigation to /photo/123 from within the app and renders it in that slot as a modal instead of a full page.
Let me give you the file structure.
app/
layout.tsx → renders {children} and {modal}
page.tsx → the feed
photo/[id]/page.tsx → full photo page (direct visit or refresh)
@modal/
default.tsx → returns null when no modal is open
(.)photo/[id]/page.tsx → the intercepted version, rendered as a modal
// app/layout.tsx
export default function RootLayout({ children, modal }) {
return <html><body>{children}{modal}</body></html>;
}
What happens: clicking a <Link href="/photo/123"> in the feed is a soft navigation, so the (.)photo route intercepts it and renders into @modal. The feed stays underneath, and the URL changes to /photo/123, so it's shareable. If someone opens that URL directly or refreshes, there's no interception, and they get the full photo/[id]/page.tsx. Closing the modal is just router.back().
The (.) means "same level", (..) means one level up, and (...) means from the app root, all based on route segments, not file system folders. The default.tsx is important; without it, a hard navigation to another route can 404 because the slot has nothing to render.
Interviewers look for: knowing that parallel plus intercepting routes exist for this, and why default.tsx is needed. It's a strong senior signal.
AdvancedWhat are the main differences between the App Router and the Pages Router?
So the Pages Router is the original model: every page is a client component that also renders on the server, and data is loaded through special functions. The App Router is built on Server Components, so components themselves fetch data, and it adds nested layouts, streaming and fine-grained caching.
Pages Router (pages/) | App Router (app/) | |
|---|---|---|
| Components | All client, all hydrate | Server by default, "use client" opt-in |
| Data fetching | getServerSideProps, getStaticProps | async components, fetch, direct DB calls |
| Layouts | One _app, manual nesting | Nested layout.tsx that persist |
| Loading and errors | Manual | loading.tsx, error.tsx, streaming |
| Mutations | API routes | Server actions and route handlers |
| Metadata | next/head | metadata and generateMetadata |
Let me give you a migration scenario. In my last project we moved incrementally, since both routers can live in one app. We started with new sections in app/, then migrated simple pages, turning getServerSideProps into an async server component. The biggest effort wasn't syntax. It was libraries that assumed everything was client-side, like a CSS-in-JS setup and a context-heavy auth wrapper, which had to move into client components.
So in practice: new projects go on the App Router. The Pages Router is still supported, and I wouldn't rewrite a stable app just for the sake of it, but I'd build new features in app/.
26. Server Components — the big idea
BasicWhat are React Server Components, and how are they different from Client Components?
So a Server Component runs only on the server. It can be async, read a database or files directly, use secrets, and its code is never sent to the browser. Only its rendered output is. A Client Component, marked with "use client", is sent to the browser as JavaScript so it can use state, effects and event handlers.
Let me give you an example.
// app/orders/page.tsx: Server Component (the default)
import { db } from "@/lib/db";
import { CancelButton } from "./CancelButton";
export default async function Orders() {
const orders = await db.order.findMany({ where: { status: "open" } });
return (
<ul>
{orders.map(o => (
<li key={o.id}>{o.number} <CancelButton id={o.id} /></li>
))}
</ul>
);
}
// app/orders/CancelButton.tsx: Client Component
"use client";
export function CancelButton({ id }) {
const [pending, setPending] = useState(false);
return <button disabled={pending} onClick={...}>Cancel</button>;
}
What happens here is the list, the database client and the query all stay on the server. The browser only downloads the JavaScript for CancelButton. No API route, no useEffect, no loading state for the list.
So the rule I follow is: pages and data-heavy parts are server components, and I push "use client" down to the small interactive leaves.
Interviewers look for: "code never ships to the browser", and the push-"use client"-down rule.
BasicWhen do you need "use client"?
So I need it whenever a component uses anything that only exists in the browser or needs to react to the user after the page has loaded. There are really four triggers:
- State and lifecycle hooks:
useState,useReducer,useEffect,useReffor DOM access. - Event handlers:
onClick,onChange,onSubmitwith a function. - Browser APIs:
window,localStorage,IntersectionObserver, geolocation. - Libraries that use those: many chart, animation and older UI libraries, and anything reading context with
useContext.
Let me give you a real example. A product page had a "Share" button that copies the link. Instead of marking the whole page as client, I made just that button a client component:
"use client";
export function ShareButton({ url }) {
const [copied, setCopied] = useState(false);
return (
<button onClick={async () => { await navigator.clipboard.writeText(url); setCopied(true); }}>
{copied ? "Copied" : "Share"}
</button>
);
}
One thing I often see misunderstood: a context provider has to be a client component, but I can still wrap my whole app in it from the root layout, and the pages inside remain server components, because they come in as children.
So in practice, if I get an error like "useState only works in Client Components", I don't slap "use client" on the page. I extract the interactive piece.
IntermediateDoes "use client" mean the component only renders in the browser?
So no, and this is probably the most common misconception. Client Components still render on the server for the initial HTML, just like components did in classic SSR. Then their JavaScript is sent to the browser and they hydrate. "Client" really means "also runs in the browser".
Let me give you an example of where that bites.
"use client";
export function Greeting() {
// Crashes during server render: window is not defined
const name = localStorage.getItem("name");
return <p>Hi {name}</p>;
}
// Fix: read browser-only things after mount
export function Greeting() {
const [name, setName] = useState(null);
useEffect(() => setName(localStorage.getItem("name")), []);
return <p>Hi {name ?? "there"}</p>;
}
What happens in the first version is the server tries to render it to produce HTML, hits localStorage, and throws a ReferenceError. The fix defers the read to an effect, which never runs on the server.
If a component truly can't render on the server, like a map library that touches window at import time, I load it with next/dynamic and ssr: false from inside a client component.
The better mental model is that "use client" doesn't mark where code runs, it marks a boundary in the module graph: this file and everything it imports becomes part of the client bundle.
Interviewers look for: "client components are still server-rendered for the first HTML", and the boundary framing.
IntermediateCan a Client Component render a Server Component?
So it can't import one, but it can receive one as a prop, usually children. That distinction is the key to composing them.
Let me give you an example.
// Doesn't work as intended: importing turns ServerStats into client code
"use client";
import ServerStats from "./ServerStats"; // now bundled for the browser
export function Collapsible() { ... <ServerStats /> ... }
// Works: pass it through as children
"use client";
export function Collapsible({ children }) {
const [open, setOpen] = useState(true);
return (
<section>
<button onClick={() => setOpen(!open)}>Toggle</button>
{open && children}
</section>
);
}
// app/page.tsx (Server Component)
<Collapsible>
<ServerStats /> {/* rendered on the server, stays there */}
</Collapsible>
What happens in the working version is that the page, a server component, renders ServerStats on the server. The result is passed to Collapsible as already-rendered output in the RSC payload. Collapsible just decides where to put it. It never needs ServerStats's code.
In the broken version, the import puts ServerStats into the client module graph. If it uses the database or async, the build fails; if not, it silently ships to the browser.
So in practice this is how I build client wrappers: tabs, modals, providers and accordions that hold server-rendered content. It's the "pass children" performance trick, just applied across the server/client boundary.
IntermediateWhat can you pass as props from a Server Component to a Client Component?
So the props have to cross the network in the RSC payload, which means they must be serialisable by React. Plain data is fine; behaviour and class instances aren't.
| Allowed | Not allowed |
|---|---|
Strings, numbers, booleans, null, undefined, BigInt | Regular functions, like an onClick defined on the server |
| Plain objects and arrays of those | Class instances, like a Prisma Decimal or a custom model |
Date, Map, Set, typed arrays | Symbols not registered with Symbol.for |
Promises (unwrap with use()) | |
| JSX, including Server Components | |
Server Functions ("use server") |
Let me give you the bug I hit most:
// Error: Functions cannot be passed directly to Client Components
<DeleteButton onDelete={() => db.post.delete({ where: { id } })} />
// Fix: a server action, which is serialisable as a reference
async function deletePost() {
"use server";
await db.post.delete({ where: { id } });
}
<DeleteButton onDelete={deletePost} />
What happens with the server action is that the client receives an ID pointing at the function, and calling it sends a POST back to the server.
The other thing I watch is size and privacy. Everything I pass ends up in the page source. So I don't pass a whole user row from the database. I pick the fields the component needs, which keeps the payload small and doesn't leak something like a password hash.
Interviewers look for: "must be serialisable", functions not allowed except server actions, and the data-leak point.
AdvancedWhat's the difference between Server Components and server-side rendering?
So they're easy to confuse but they solve different problems. SSR is about where the first HTML comes from: it renders your whole tree, client components included, to an HTML string so the page appears fast. Server Components are about which components exist on the client at all: they run only on the server and never ship code or hydrate.
Let me put them side by side.
| SSR | Server Components | |
|---|---|---|
| Output | HTML | RSC payload, a serialised description of the tree |
| When | Initial page load | Initial load and every client-side navigation or refresh |
| JS shipped for the component | Yes, it hydrates | None |
| Can use state and effects | Yes, in the browser afterwards | No |
| Needs a server at request time | Yes, unless prerendered | No, can run at build time |
In Next.js they work together. On the first request, Server Components run and produce the RSC payload, then SSR turns that payload plus the client components into HTML. The browser shows the HTML, then hydrates only the client components using the payload. On a later <Link> navigation, there's no HTML at all: the server sends just the RSC payload and React merges it into the existing tree, keeping client state.
So you can have SSR without RSC, which is the Pages Router, and RSC without SSR, for example a static build or an SPA that fetches RSC payloads. The benefit of RSC isn't a faster first paint, it's a smaller bundle and data fetching colocated with components.
Interviewers look for: HTML versus RSC payload, and that RSC also powers client-side navigations. This is the question that separates people who've used the App Router from people who understand it.
AdvancedHow do you make sure server-only code and secrets never leak to the client?
So there are two ways secrets leak. One is a module with server code getting imported into a client component, which pulls it into the browser bundle. The other is passing sensitive data as props, which puts it in the RSC payload. I guard against both.
Let me give you what I set up.
// lib/db.ts
import "server-only"; // build error if imported from client code
import { PrismaClient } from "@prisma/client";
export const db = new PrismaClient();
// lib/users.ts: a data access layer that returns safe DTOs
import "server-only";
export async function getProfile(id) {
const u = await db.user.findUnique({ where: { id } });
return { id: u.id, name: u.name, avatarUrl: u.avatarUrl }; // no email, no hash
}
What happens with server-only is that if anyone imports that file from a "use client" module, even indirectly, the build fails instead of silently bundling it. There's a matching client-only package for the reverse.
For environment variables, only ones prefixed with NEXT_PUBLIC_ get inlined into client code. DATABASE_URL or STRIPE_SECRET_KEY without the prefix are undefined in the browser. So the rule is: never put a secret behind NEXT_PUBLIC_.
For props, I go through a data access layer that returns only the fields the UI needs, so a component can't accidentally receive a full user row. React also has experimental taint APIs to flag objects that must never cross the boundary.
And one more: server actions are public HTTP endpoints. Even if a button is only shown to admins, the action itself must check the session and permissions, because anyone can call it directly.
27. Next.js Request Lifecycle
BasicWalk me through what happens when a user opens a page in a Next.js App Router app.
So I think of it as two phases: the server builds and streams the page, then the browser makes the interactive parts come alive. Let me take a real URL, /shop/shoes, and go step by step.
// 1. GET /shop/shoes arrives
// 2. middleware.ts (proxy.ts in Next 16) runs: redirect, rewrite or continue
// 3. Route matching: app/shop/[slug]/page.tsx, slug = "shoes"
// + app/layout.tsx, app/shop/layout.tsx, nearest loading.tsx / error.tsx
// 4. Server Components render: await db / fetch, reuse caches where allowed
// 5. Stream: HTML + RSC payload, Suspense boundaries arrive later
// 6. Browser paints the HTML (readable, not yet interactive)
// 7. JS loads: React, Next runtime, Client Component chunks only
// 8. Hydration: handlers attached to Client Components
// 9. Later <Link> clicks: only the RSC payload for the changed segment
What I'd stress is that the server does the heavy lifting once. Server Components run only there and ship zero JavaScript. The browser gets HTML straight away, so the user can read the page, and then React hydrates just the "use client" parts. With <Suspense>, the shell goes first and the slow sections stream in when their data is ready, so one slow query doesn't block the whole page.
After the first load it's a different game. Navigation doesn't reload the page. The router fetches the RSC payload for the new segment and keeps shared layouts, including their state.
Interviewers look for: middleware before routing, "streamed HTML plus RSC payload", and the point that only Client Components hydrate.
BasicWhat's the difference between the HTML and the RSC payload in a response?
So the HTML is for the browser to paint something immediately. The RSC payload is for React: a compact description of the rendered Server Component tree that React uses to build and later update the UI.
Let me give you an example. For a product page, the payload roughly says:
// conceptually, not the real wire format
<h1>Trail Runner</h1> // already-rendered output of Server Components
<AddToCart id="p_42" /> // "a Client Component goes here,
// its code is in chunk 7, props are { id }"
<Suspense>…streams later…</Suspense>
What happens on the first visit is that both travel together. The HTML gets pixels on screen, and the payload tells React how the Server output and the Client Component holes fit together during hydration. On later <Link> navigations, there's no HTML at all. Only the payload for the changed segment comes back, and React merges it into the existing tree.
One gotcha I've hit: anything a Server Component passes as a prop to a Client Component ends up in that payload. So if I pass a whole user object with an email and a password hash, it's visible in the page source. I pass only the fields the client needs.
Interviewers look for: knowing that props crossing the server/client boundary are serialised and visible to the user.
IntermediateWhat happens when I click a <Link>? What's kept and what's refetched?
So there's no full page reload. The router asks the server only for the RSC payload of the segments that changed, and everything above the change, the shared layouts, stays mounted with its state.
app/
layout.tsx // kept
dashboard/
layout.tsx // kept: sidebar, its open/closed state survives
orders/page.tsx // from here...
settings/page.tsx // ...to here: only this segment is fetched
Going from /dashboard/orders to /dashboard/settings, the dashboard layout doesn't re-render and the sidebar's useState survives. Only the page segment is swapped.
Two more things make it feel instant. First, prefetching: in production, when a <Link> scrolls into the viewport, Next prefetches the route. For static routes it prefetches the whole thing; for dynamic ones, down to the nearest loading.tsx, so it can show the skeleton instantly on click. Second, the client-side Router Cache keeps visited segments in memory for back and forward navigation.
One gotcha: since Next 15, page segments are not reused from the Router Cache on normal navigation by default (staleTimes.dynamic is 0), so dynamic pages refetch. If a teammate expects stale data to linger, or the opposite, I check that setting first.
Interviewers look for: "layouts are preserved", prefetch to the loading boundary, and awareness of the client Router Cache.
IntermediateThe page is visible but buttons don't respond for a second. What's going on?
So that's the gap between paint and hydration. The HTML arrived and was painted, but React hasn't loaded and attached handlers to the Client Components yet. Until React's code has loaded, a click simply does nothing. Once it has, clicks inside a Suspense boundary that hasn't hydrated yet are held and replayed after it does.
In my last project the product page had a big "reviews carousel" Client Component near the top, pulling in a heavy slider library. The Add to Cart button sat in the same hydration unit, so it waited on that bundle. What fixed it:
// before: one huge client tree
"use client";
export default function ProductPage() { /* everything */ }
// after: server page, small client islands
export default async function ProductPage({ params }) {
const product = await getProduct((await params).slug);
return (
<>
<h1>{product.name}</h1> {/* no JS */}
<AddToCart id={product.id} /> {/* tiny client island */}
<Suspense fallback={<ReviewsSkeleton />}>
<Reviews slug={product.slug} /> {/* hydrates on its own */}
</Suspense>
</>
);
}
What happens here is that "use client" moves down to the leaves, so less JavaScript ships, and the Suspense boundary gives React selective hydration: each boundary hydrates independently, and React prioritises the part the user is interacting with.
So in practice I measure with the Performance panel and INP, then shrink the client bundle rather than just adding spinners.
IntermediateWhere does a console.log print in a Server Component, and in a Client Component?
So a Server Component's log only ever prints in the server terminal, or in production, the server logs. A Client Component's log prints in both: once on the server during SSR, then in the browser on hydration and every re-render.
// app/page.tsx (Server Component)
export default async function Page() {
console.log("server render"); // terminal only
return <Counter />;
}
// app/Counter.tsx
"use client";
export default function Counter() {
console.log("client render"); // terminal (SSR) AND browser console
const [n, setN] = useState(0);
return <button onClick={() => setN(n + 1)}>{n}</button>;
}
The reason this matters is that "Client Component" doesn't mean "runs only in the browser". It means "also runs in the browser". So window or localStorage at the top level of a Client Component will crash during SSR. I access those inside useEffect or an event handler.
In dev, Next also forwards server logs to the browser console with a "Server" badge, which confuses people, but the code still ran on the server.
Interviewers look for: "Client Components are pre-rendered on the server too", which explains most window is not defined errors.
AdvancedA page has a slow Time to First Byte. How do you find where in the lifecycle the time goes?
So I split it along the lifecycle: middleware, route rendering, data, and whether the route is static or dynamic at all. There are really four things I check.
- Is it dynamic when it shouldn't be? The build output marks each route static or dynamic. Calling
cookies(),headers()or readingsearchParamsin a layout makes everything under it dynamic. - Middleware. It runs before everything, even for cached pages. A database call or slow external fetch there adds to every request.
- Waterfalls. Sequential awaits in layouts and pages, including nested ones, add up.
- Missing Suspense. Without a boundary, the whole response waits for the slowest query.
// instrumentation.ts: OpenTelemetry spans for each render and fetch
import { registerOTel } from "@vercel/otel";
export function register() {
registerOTel({ serviceName: "shop-web" });
}
In my last project, the root layout read cookies() to pick a theme. That one line made every marketing page dynamic, so TTFB went from a CDN hit to a full server render. We moved the theme to a client-side script and read the cookie only in the account area. The pages became static again.
So in practice: check the build output, trace with instrumentation.ts, fix the dynamic leak first, then the waterfalls, then add Suspense so the shell streams immediately.
Interviewers look for: knowing that one dynamic API high in the tree de-optimises everything below it.
AdvancedWalk me through a Server Action round trip, end to end.
So a Server Action is a POST to the current URL, with an action ID and the serialised arguments. Next routes it to the function on the server, runs it, and sends back the result and, if I revalidated, the fresh RSC payload in the same response.
// app/posts/actions.ts
"use server";
export async function createPost(formData: FormData) {
const user = await requireUser(); // 1. auth
const title = String(formData.get("title") ?? "");
if (!title) return { error: "Title required" }; // 2. validate
await db.post.create({ data: { title, authorId: user.id } }); // 3. write
revalidatePath("/posts"); // 4. mark stale
}
What happens in order: the form submits, and at build time Next swapped the function reference for an ID. The browser POSTs with a Next-Action header and the form data. On the server, middleware runs first, then the action. revalidatePath marks the cache stale, and because the action ran inside a request, Next re-renders the affected route and ships the updated payload back with the return value. One round trip, and the list updates without a separate refetch.
Two gotchas I've hit. redirect() inside an action works by throwing, so wrapping it in try/catch swallows it. And actions from the same client are queued, so they don't run in parallel. That's fine for mutations, but it's a reason not to use them for reads.
Interviewers look for: "it's a POST endpoint with an ID", and that the revalidated UI comes back in the same response.
28. Data Fetching & Caching
BasicHow do you fetch data in the App Router? How is it different from a client-side fetch?
So in a Server Component I just make it async and await the data, directly from the database or an API. No useEffect, no loading state inside the component, and the secrets never leave the server.
// Server Component
export default async function OrdersPage() {
const orders = await db.order.findMany({ take: 20 }); // straight to the DB
return <OrderList orders={orders} />;
}
// Client Component, when data depends on interaction
"use client";
export function SearchResults({ q }) {
const { data, isLoading } = useQuery({
queryKey: ["search", q],
queryFn: () => fetch(`/api/search?q=${q}`).then(r => r.json()),
});
// ...
}
The reason I default to the server version is that the data arrives with the HTML: no loading spinner after hydration, no extra round trip, a smaller bundle, and loading.tsx handles the waiting state for me.
I switch to a client-side fetch, usually with TanStack Query, when the data is driven by something that happens after load: a search box, infinite scroll, polling, or anything very user-specific that I don't want to re-render on the server for every keystroke.
Interviewers look for: "server by default, client for interactive data", with a reason for each.
BasicWhat does next: { revalidate: 60 } actually do?
So it's time-based revalidation, stale-while-revalidate. The result is cached, and once it's older than 60 seconds, the next request still gets the stale copy instantly but triggers a background refresh. The request after that gets the fresh data.
const res = await fetch("https://api.shop.com/products", {
next: { revalidate: 60 },
});
// t=0s first request -> fetch, cache it
// t=30s request -> cached (fresh)
// t=90s request -> cached (stale!) + background refetch
// t=91s request -> new data
What people get wrong is thinking it refreshes every 60 seconds on a timer. It doesn't. If nobody visits for an hour, nothing is refetched. And the first visitor after the window still sees old data once.
If that one stale response isn't acceptable, like a price after an admin edits it, I don't lower the number. I use on-demand revalidation instead: tag the fetch and call revalidateTag from the action that changes it. So in practice I use time-based revalidation as a safety net and tags for correctness.
Interviewers look for: "the first request after expiry still gets stale data." Most candidates miss that.
IntermediateExplain the different caches in Next.js.
So there are four, two per request and two across requests. When I debug stale data, I literally walk through this table.
| Cache | Where | What it stores | Lifetime |
|---|---|---|---|
| Request memoization | Server | Same fetch (or cache()-wrapped call) in one render | One request |
| Data Cache | Server | Fetch results | Across requests and deploys, until revalidated |
| Full Route Cache | Server | Rendered HTML + RSC payload of static routes | Until revalidated or redeployed |
| Router Cache | Browser | RSC payloads of visited/prefetched segments | Session, or until router.refresh() |
Let me give you an example of memoization. The layout and the page both need the user:
import { cache } from "react";
export const getUser = cache(async (id: string) => {
return db.user.findUnique({ where: { id } }); // runs once per request
});
Both call getUser(id), and the DB is hit once. I don't need to prop-drill the user. For fetch this happens automatically; for an ORM I wrap with React's cache.
In Next 16 with Cache Components turned on, the model gets simpler: data isn't cached unless I opt in with the "use cache" directive and set its lifetime with cacheLife and cacheTag. I like that, because caching becomes visible in the code.
Interviewers look for: the four names, where each lives, and that the Router Cache is on the client, so server revalidation alone doesn't clear it for an open tab.
IntermediateWhat changed about caching defaults in Next 15 and 16?
So the big shift is from "cached unless you opt out" to "dynamic unless you opt in". Next 14 cached aggressively by default and it surprised a lot of teams, me included.
| Next 14 | Next 15 | Next 16 (Cache Components) | |
|---|---|---|---|
fetch | cached by default | not cached by default | not cached; opt in with "use cache" |
| GET route handlers | static by default | dynamic by default | dynamic by default |
| Router Cache for pages | 30s reuse | staleTimes.dynamic = 0 | same |
| Request APIs | sync | params, cookies() async | async only |
What that means in practice is that a page that "worked" in 14 because fetch was silently cached can get slower after an upgrade, because now it hits the API on every request. That's a feature: it's correct by default, and I add caching deliberately.
// Next 16, with cacheComponents: true in next.config
async function getProducts() {
"use cache";
cacheLife("hours");
cacheTag("products");
return db.product.findMany();
}
In 16 the revalidation APIs split too: updateTag in a Server Action, when the user must see their own write immediately, and revalidateTag(tag, "max") for stale-while-revalidate elsewhere. I'd always double-check the exact version's docs, because this area has moved every release.
Interviewers look for: knowing the direction (opt-in caching), not memorised flags. Saying "I'd be explicit on every fetch that matters" is a strong answer.
IntermediateHow do you avoid request waterfalls in Server Components?
So a waterfall is when each await waits for the previous one even though they don't depend on each other. There are three tools I use: Promise.all, pushing awaits down into separate components under Suspense, and starting a promise early.
// waterfall: ~300ms + ~400ms + ~200ms
const user = await getUser(id);
const orders = await getOrders(id);
const recs = await getRecommendations(id);
// parallel: ~400ms, the slowest one
const [user, orders, recs] = await Promise.all([
getUser(id), getOrders(id), getRecommendations(id),
]);
// even better: don't block the page on the slow one
export default async function Page({ params }) {
const { id } = await params;
const user = await getUser(id);
return (
<>
<Profile user={user} />
<Suspense fallback={<RecsSkeleton />}>
<Recommendations id={id} /> {/* awaits its own data */}
</Suspense>
</>
);
}
The hidden waterfall I see most is between a layout and a page. People assume they're sequential, but Next renders layouts and pages in parallel. The real waterfall is usually parent-child: a component awaits, and only then renders a child that awaits its own data. Fixing that means starting the child's fetch in the parent without awaiting and passing the promise down, then reading it with use() in a Client Component or awaiting it in a Server one.
So in practice: parallel when all data is needed before paint, Suspense when some of it can arrive later.
AdvancedA user edits their profile but still sees old data after saving. How do you debug it?
So I go cache by cache, from the server outward, because each layer has a different fix.
"use server";
export async function updateProfile(formData: FormData) {
const user = await requireUser();
await db.user.update({ where: { id: user.id }, data: parse(formData) });
// Next 15: revalidateTag("user:" + user.id) or revalidatePath("/profile")
// Next 16: read-your-own-writes inside a Server Action
updateTag(`user:${user.id}`);
}
// where the data is read
async function getProfile(id: string) {
"use cache";
cacheTag(`user:${id}`);
return db.user.findUnique({ where: { id } });
}
What I check, in order:
- Data Cache: is the read cached, and does the mutation revalidate the same tag or path? A typo in the tag is a classic.
- Full Route Cache: if
/profilewas static, the pre-rendered page itself needsrevalidatePath. - Router Cache: if the mutation happened outside a Server Action, say through a client fetch to an API route, the client still has the old segments. I call
router.refresh()after it. - Outside Next: a CDN or TanStack Query cache on the client holding its own copy.
In my last project it was number 3: the form posted to a route handler, the server cache was revalidated correctly, but the open tab kept the old payload until a hard reload. Switching the form to a Server Action fixed it, because the action returns the fresh tree in the same response.
Interviewers look for: a systematic, layer-by-layer approach rather than "I'd add no-store everywhere".
AdvancedHow do you cache data that doesn't come from fetch, like a database query?
So fetch gets special treatment, but an ORM call doesn't. For a DB query I need two separate things: de-duplication within a request, and caching across requests.
import { cache } from "react";
// 1. Per-request dedupe: layout + page + metadata share one query
export const getPost = cache(async (slug: string) =>
db.post.findUnique({ where: { slug } })
);
// 2. Cross-request cache
// Next 16 with Cache Components:
export async function getPopularPosts() {
"use cache";
cacheLife("minutes");
cacheTag("posts");
return db.post.findMany({ orderBy: { views: "desc" }, take: 10 });
}
// Next 14/15: unstable_cache
import { unstable_cache } from "next/cache";
export const getPopularPostsLegacy = unstable_cache(
() => db.post.findMany({ orderBy: { views: "desc" }, take: 10 }),
["popular-posts"],
{ revalidate: 300, tags: ["posts"] }
);
What happens with React's cache is that it only lives for one server render, so it's always safe. The cross-request cache is where I have to be careful. The cache key is built from the function and its arguments, so I never read cookies() inside a cached function. If I did, one user's data could be served to another. I read the user ID outside and pass it in as an argument.
So in practice: cache() everywhere for dedupe, and cross-request caching only for data that's shared or keyed explicitly by the user.
Interviewers look for: the distinction between per-request memoization and a persistent cache, and the cross-user leak risk.
29. Server Actions & Forms
BasicWhat is a Server Action, and why would you use one?
So a Server Action is an async function marked "use server" that I can call from a component as if it were local, but it runs on the server. Next turns the call into a POST request for me, so I don't write an API route or a fetch call.
// app/todos/actions.ts
"use server";
import { revalidatePath } from "next/cache";
export async function addTodo(formData: FormData) {
const text = String(formData.get("text") ?? "").trim();
if (!text) return;
await db.todo.create({ data: { text } });
revalidatePath("/todos");
}
// app/todos/page.tsx (Server Component)
export default function Page() {
return (
<form action={addTodo}>
<input name="text" />
<button>Add</button>
</form>
);
}
The reason I like it is that there's no onSubmit, no useState for the input, no JSON handling, and it's typed end to end. Because it's a real <form action>, it also works before the JavaScript loads: the browser just does a normal form POST. That's progressive enhancement for free.
I use them for mutations from my own UI: creating, updating, deleting. For reads, I stick to Server Components.
Interviewers look for: "it's a mutation primitive, not a data-fetching one", plus progressive enhancement.
BasicHow do you show a pending state and validation errors with a Server Action?
So in React 19 I use useActionState. It gives me the last result the action returned, a wrapped action to pass to the form, and an isPending flag. For a submit button deep in the form, useFormStatus also works.
"use client";
import { useActionState } from "react";
import { signUp } from "./actions";
export function SignUpForm() {
const [state, formAction, isPending] = useActionState(signUp, { errors: {} });
return (
<form action={formAction}>
<input name="email" defaultValue={state.values?.email} />
{state.errors.email && <p role="alert">{state.errors.email}</p>}
<button disabled={isPending}>{isPending ? "Creating…" : "Sign up"}</button>
</form>
);
}
// actions.ts: signature changes to (prevState, formData)
"use server";
export async function signUp(prev, formData: FormData) {
const email = String(formData.get("email") ?? "");
if (!email.includes("@")) return { errors: { email: "Enter a valid email" }, values: { email } };
await createUser(email);
redirect("/welcome");
}
What happens here is that the action returns plain data, the errors, and React re-renders with that as state. The gotcha is that the action's signature changes: with useActionState the first argument is the previous state, and the form data comes second.
One more thing I've hit: React 19 resets uncontrolled form fields after a successful action. So on an error, I send the submitted values back and use them as defaultValue, or the user loses what they typed.
IntermediateServer Action or Route Handler? How do you choose?
So my rule is: if my own React UI is changing data, a Server Action. If something else needs to call it, or I need control over the HTTP details, a Route Handler.
| Use case | Choice | Why |
|---|---|---|
| Form submit, like button, delete item | Server Action | Typed call, revalidation in the same round trip |
| Stripe or GitHub webhook | Route Handler | External caller, needs raw body and a stable URL |
| Mobile app or third-party API | Route Handler | Public, versioned contract |
| Reading data for a page | Server Component | Neither: just await it |
| Client-side search as you type | Route Handler (GET) | Cacheable, parallel; actions are queued POSTs |
That last row is worth explaining. Server Actions are always POST and run one at a time per client, so using them for reads means no HTTP caching and requests queuing behind each other. A search box firing on every keystroke would feel sluggish.
In my last project we had both for the same thing: a Server Action for the web "add to cart" button, and a Route Handler for the mobile app. Both called the same cartService.addItem(), so the business logic lived in one place.
Interviewers look for: the POST-only, serialised nature of actions, and keeping business logic in a shared layer.
IntermediateAre Server Actions secure by default?
So no. They're public HTTP endpoints with an obscure ID. Anyone can find the ID in the client bundle and call it with any arguments. So every action does its own auth, authorisation and validation, exactly like an API route.
"use server";
import { z } from "zod";
const schema = z.object({ postId: z.string().uuid() });
export async function deletePost(input: unknown) {
const session = await auth(); // who are you?
if (!session) throw new Error("Unauthorized");
const { postId } = schema.parse(input); // never trust the shape
const post = await db.post.findUnique({ where: { id: postId } });
if (post?.authorId !== session.user.id) { // may you do THIS?
throw new Error("Forbidden");
}
await db.post.delete({ where: { id: postId } });
revalidateTag("posts");
}
The mistake I see most is: "the delete button only renders for the author, so it's fine". Hiding a button isn't authorisation. Someone can call the action with another post's ID.
What Next does give me: unused actions are dropped from the build, action IDs are non-deterministic, and closed-over variables in inline actions are encrypted. There's also a built-in Origin check against CSRF. But none of that checks who is calling, so I also keep a server-only data layer and don't rely on middleware alone.
Interviewers look for: "treat it as a public API endpoint", and checking ownership, not just login.
IntermediateHow do you do an optimistic update with a Server Action?
So I use useOptimistic. It gives me a temporary version of the state that shows the change immediately while the action runs. When the action finishes and the real data comes back, React throws the optimistic copy away automatically.
"use client";
import { useOptimistic, startTransition } from "react";
import { toggleLike } from "./actions";
export function LikeButton({ postId, liked, count }) {
const [optimistic, setOptimistic] = useOptimistic(
{ liked, count },
(state, nextLiked: boolean) => ({
liked: nextLiked,
count: state.count + (nextLiked ? 1 : -1),
})
);
function onClick() {
startTransition(async () => {
setOptimistic(!optimistic.liked); // instant
await toggleLike(postId); // real work; revalidates
});
}
return <button onClick={onClick}>{optimistic.liked ? "Liked" : "Like"} {optimistic.count}</button>;
}
What happens is that the optimistic value only lives for the duration of the transition. If the action succeeds, the revalidated props arrive with the real liked and count. If it throws, the transition ends, and the UI falls back to the original props, so the rollback is automatic. I still show a toast so the user knows it failed.
So in practice I use it for cheap, likely-to-succeed actions: likes, toggles, adding a chat message. Not for payments.
AdvancedHow do you pass extra arguments to a Server Action, and what can you pass?
So the clean way is .bind. It pre-fills arguments and still works with progressive enhancement, because Next encodes the bound values into the form.
// Server Component
import { updateItem } from "./actions";
export function EditItem({ item }) {
const update = updateItem.bind(null, item.id); // first arg fixed
return (
<form action={update}>
<input name="name" defaultValue={item.name} />
<button>Save</button>
</form>
);
}
// actions.ts
"use server";
export async function updateItem(id: string, formData: FormData) {
// id comes from the client too: still check ownership!
}
An alternative is a hidden input, <input type="hidden" name="id" value={item.id} />. It's fine, but the value is visible in the HTML either way. Bound arguments look hidden, but they're not secret. The user can change them.
On what I can pass: arguments and return values must be serialisable by React. Strings, numbers, plain objects, arrays, Date, Map, Set, FormData, typed arrays and promises are fine. Class instances, functions other than other Server Actions, and DOM events aren't. The bug I've seen is calling onClick={() => save(event)} and passing the React event, which throws.
Interviewers look for: .bind, the serialisation boundary, and "bound values are client-controlled".
30. Route Handlers & Middleware
BasicHow do you create an API endpoint in the App Router?
So I create a route.ts file inside app, and export one function per HTTP method. It uses the standard Web Request and Response APIs, so it feels a lot like writing a fetch handler.
// app/api/products/[id]/route.ts
import { NextResponse } from "next/server";
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params; // async since Next 15
const product = await db.product.findUnique({ where: { id } });
if (!product) return NextResponse.json({ error: "Not found" }, { status: 404 });
return NextResponse.json(product);
}
export async function DELETE(request: Request, { params }) {
const session = await auth();
if (!session?.user.isAdmin) return new Response(null, { status: 403 });
await db.product.delete({ where: { id: (await params).id } });
return new Response(null, { status: 204 });
}
A couple of rules: a folder can't have both page.tsx and route.ts at the same level, and methods I don't export return a 405 automatically. Query params come from new URL(request.url).searchParams, or request.nextUrl if I type it as NextRequest.
I reach for these when something outside my React UI calls the server: webhooks, a mobile app, a cron job.
BasicWhat is middleware good for, and what shouldn't go in it?
So middleware runs before a request reaches any route, even a cached one. It's good for fast, request-shaping decisions: redirects, rewrites, headers, cookies. In Next 16 the file is renamed from middleware.ts to proxy.ts, which says the purpose better: it sits in front of the app.
// middleware.ts (proxy.ts in Next 16, exporting `proxy`)
import { NextResponse, type NextRequest } from "next/server";
export function middleware(request: NextRequest) {
// locale redirect: / -> /en
if (request.nextUrl.pathname === "/") {
const lang = request.headers.get("accept-language")?.startsWith("de") ? "de" : "en";
return NextResponse.redirect(new URL(`/${lang}`, request.url));
}
// optimistic auth check: only "is there a session cookie?"
if (!request.cookies.has("session") && request.nextUrl.pathname.startsWith("/app")) {
return NextResponse.redirect(new URL("/login", request.url));
}
return NextResponse.next();
}
Good uses: locale detection, A/B test bucketing with a rewrite, a quick "has a session cookie" redirect, adding security headers.
What I keep out: database queries, calls to slow APIs, and anything heavy, because it adds latency to every matching request. And it's not where I do the real authorisation. That belongs next to the data.
Interviewers look for: "fast and cheap only", and that it runs on every matching request, including cached pages.
IntermediateHow does the middleware matcher work, and why does it matter?
So without a matcher, middleware runs on every request, including JS chunks, images and the favicon. The matcher limits it to the paths I care about, which keeps asset requests fast and avoids weird bugs.
export const config = {
matcher: [
// everything except static files, images and metadata files
"/((?!_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml).*)",
],
};
// or explicit and narrow
export const config = {
matcher: ["/dashboard/:path*", "/settings/:path*"],
};
The bug I've hit with this: an auth middleware with no matcher redirected requests for /_next/static/... to /login for logged-out users. The login page itself then couldn't load its CSS and JS. Adding the exclusion fixed it.
Two details: the values must be constants, because they're analysed at build time, so I can't build them from variables. And :path* means zero or more segments, so /dashboard itself matches too. For logic too complex for a pattern, I match broadly and branch on request.nextUrl.pathname inside.
IntermediateIs protecting routes in middleware enough for authentication?
So no, and there's a real-world reason I say that. Middleware is a good first gate, but I always check auth again where the data is read or changed.
Let me give you an example. In March 2025 there was a Next.js vulnerability, CVE-2025-29927, where sending a crafted x-middleware-subrequest header made self-hosted apps skip middleware entirely. Apps that relied only on middleware for auth exposed their dashboards. Apps that also checked in the data layer were fine.
// lib/dal.ts: data access layer
import "server-only";
import { cache } from "react";
export const verifySession = cache(async () => {
const session = await decrypt((await cookies()).get("session")?.value);
if (!session?.userId) redirect("/login");
return session;
});
export async function getInvoices() {
const { userId } = await verifySession(); // checked every time
return db.invoice.findMany({ where: { userId } });
}
What happens here is that every read goes through a function that checks the session, and cache means it's cheap even if called ten times in one render. Layouts are another trap: they don't re-render on client navigation, so an auth check in a layout doesn't run for each page.
So in practice: middleware for the fast redirect and a good UX, the data layer for actual security.
Interviewers look for: defence in depth, and ideally awareness that layouts don't re-run on navigation.
IntermediateHow would you handle a Stripe webhook in Next.js?
So a Route Handler, because Stripe needs a stable URL and I need the raw request body to verify the signature. There are three things I get right: verify, be idempotent, and respond fast.
// app/api/webhooks/stripe/route.ts
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(request: Request) {
const body = await request.text(); // raw, NOT request.json()
const sig = request.headers.get("stripe-signature")!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
} catch {
return new Response("Bad signature", { status: 400 });
}
// idempotency: Stripe may send the same event more than once
const seen = await db.webhookEvent.findUnique({ where: { id: event.id } });
if (seen) return new Response("ok");
if (event.type === "checkout.session.completed") {
await fulfilOrder(event.data.object);
}
await db.webhookEvent.create({ data: { id: event.id } });
return new Response("ok");
}
The gotcha I've hit is parsing JSON first. The signature is computed over the exact bytes, so re-stringifying breaks the check. I also make sure the path is excluded from my auth middleware, because Stripe has no session cookie.
In production I'd do the heavy work, emails and PDFs, in a queue and return 200 quickly, since Stripe retries on timeouts.
AdvancedAre GET Route Handlers cached? How do you control it?
So since Next 15, no: GET handlers are dynamic by default and run on every request. In Next 14 a GET that didn't read the request was static by default, and that caused real bugs, like an "/api/time" endpoint returning the build time forever.
// opt a handler into static generation
export const dynamic = "force-static";
export const revalidate = 3600; // rebuild at most hourly
export async function GET() {
const rates = await getExchangeRates();
return Response.json(rates);
}
// or keep it dynamic, but let the CDN cache it
export async function GET() {
const data = await getLeaderboard();
return Response.json(data, {
headers: { "Cache-Control": "public, s-maxage=60, stale-while-revalidate=300" },
});
}
What I choose depends on the data. For something the same for everyone and slow to compute, like exchange rates, force-static with a revalidate works well. For something that should be fresh but can tolerate a minute, I prefer HTTP cache headers, because then the CDN and browser do the work and I can reason about it with normal HTTP tools.
And I never cache a handler that reads cookies or auth headers at a shared layer. With s-maxage the CDN would serve one user's response to another. For those I send private, no-store.
Interviewers look for: the version change, and awareness that shared caching of personalised responses leaks data.
31. Images, Fonts & SEO
BasicWhat does next/image do that a plain <img> doesn't?
So it handles the performance work I'd otherwise forget: it resizes, converts to modern formats, lazy-loads, and reserves space so nothing jumps.
import Image from "next/image";
<Image
src="/hero.jpg"
alt="Runner on a mountain trail"
width={1200}
height={600}
priority // LCP image: load eagerly, high priority
/>
What happens here is:
- It generates a
srcsetwith several widths, so a phone downloads a small file, not the 1200px one. - It serves WebP or AVIF when the browser supports it, often much smaller than JPEG.
- It lazy-loads by default, so below-the-fold images wait until the user scrolls near them.
widthandheightgive it an aspect ratio, so the browser reserves the space and there's no layout shift. That's CLS.
One gotcha I've hit: the hero image was lazy-loaded by default, which hurt LCP badly. Marking the one above-the-fold image with priority fixed it. In Next 16 that prop is deprecated in favour of preload, but the idea's the same: only the LCP image, not every image.
Interviewers look for: connecting each feature to a Core Web Vital: LCP, CLS, bytes.
BasicHow do you set the page title and meta tags in the App Router?
So I export a metadata object from a layout or page for static values, or a generateMetadata function when it depends on data. Next merges them down the tree and renders the tags on the server.
// app/layout.tsx
export const metadata = {
metadataBase: new URL("https://shop.com"),
title: { template: "%s | Shop", default: "Shop" },
description: "Running gear for trails and roads",
};
// app/products/[slug]/page.tsx
export async function generateMetadata({ params }) {
const { slug } = await params;
const product = await getProduct(slug); // same call as the page: deduped
return {
title: product.name, // becomes "Trail Runner | Shop"
description: product.summary,
alternates: { canonical: `/products/${slug}` },
openGraph: { images: [product.imageUrl] },
};
}
The reason this matters for SEO is that the tags are in the initial HTML, so crawlers and link-preview bots see them without running JavaScript. The title template saves repeating the brand in every page.
One gotcha: generateMetadata and the page both need the product. If getProduct uses fetch or is wrapped in React's cache, it runs once. Otherwise I'd hit the database twice per request.
IntermediateWhat is the sizes prop, and when do you use fill?
So sizes tells the browser how wide the image will be on screen at each breakpoint, before CSS has loaded. The browser uses it to pick the right file from the srcset. Without it, a responsive image can end up downloading the largest version.
// product grid: 1 column on mobile, 2 on tablet, 4 on desktop
<div className="card-image"> {/* position: relative; aspect-ratio: 4/3 */}
<Image
src={product.imageUrl}
alt={product.name}
fill // fill the parent box
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw"
style={{ objectFit: "cover" }}
/>
</div>
I use fill when I don't know the image's dimensions, like user uploads or a CMS, or when the layout decides the size. The parent must be positioned, and I give it an aspect-ratio so there's still no layout shift.
In my last project, the product grid on desktop was downloading 1920px images for 300px cards, because we had fill without sizes. The default is 100vw. Adding the correct sizes cut image bytes on that page by about 70 percent.
Interviewers look for: that sizes drives which file is downloaded, and that fill without it wastes bandwidth.
IntermediateHow does next/font avoid layout shift and privacy issues?
So it downloads the font at build time and serves it from my own domain, and it generates a fallback font with adjusted metrics, so the text doesn't reflow when the real font arrives.
// app/layout.tsx
import { Inter } from "next/font/google";
const inter = Inter({
subsets: ["latin"],
display: "swap",
variable: "--font-inter", // expose as a CSS variable
});
export default function RootLayout({ children }) {
return (
<html lang="en" className={inter.variable}>
<body>{children}</body>
</html>
);
}
What happens with a normal Google Fonts link is that the browser shows a fallback font, then swaps. If the fallback's letters are wider or narrower, lines rewrap and the page jumps. next/font computes size-adjust and other overrides on the fallback so it takes up nearly the same space. The swap is barely visible.
Self-hosting also means no runtime request to Google, which is one less connection and better for GDPR. That's a real concern in the EU. For custom fonts I use next/font/local the same way.
So in practice I load fonts once in the root layout, use the CSS variable in Tailwind or my CSS, and limit the subsets and weights to what we use.
IntermediateHow do you use images from an external domain, like a CMS?
So I have to allow the domain in next.config with remotePatterns. Next refuses to optimise images from unknown hosts, because otherwise anyone could use my server as a free image-resizing proxy.
// next.config.ts
const config = {
images: {
remotePatterns: [
{ protocol: "https", hostname: "images.ctfassets.net", pathname: "/abc123/**" },
new URL("https://cdn.shop.com/products/**"), // shorthand form
],
},
};
export default config;
I keep the pattern as narrow as I can, including the path, not a wildcard on the whole domain. If our CMS supports a user-upload bucket, a wide pattern lets anyone point our optimiser at arbitrary content.
The trade-off to know: optimisation costs CPU on my server, or money on Vercel. If the CMS already has an image API that resizes and converts, like Contentful or Cloudinary, I write a custom loader that builds their URLs instead. Then Next only generates the srcset and the CMS does the work.
AdvancedHow would you set up SEO for a large e-commerce site in Next.js?
So I think of it in three layers: crawlability, per-page metadata, and speed. Next has a file convention for most of it.
// app/sitemap.ts: generated, split if over 50,000 URLs
export default async function sitemap() {
const products = await db.product.findMany({ select: { slug: true, updatedAt: true } });
return products.map(p => ({
url: `https://shop.com/products/${p.slug}`,
lastModified: p.updatedAt,
}));
}
// app/robots.ts
export default function robots() {
return { rules: { userAgent: "*", disallow: ["/cart", "/account"] },
sitemap: "https://shop.com/sitemap.xml" };
}
// app/products/[slug]/page.tsx: structured data for rich results
<script type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(productJsonLd).replace(/</g, "\\u003c") }} />
Then the details that matter at scale:
- Rendering: top products pre-rendered with
generateStaticParams, the long tail generated on demand and cached. - Canonicals: filter and sort URLs like
?color=redpoint to the base category, so we don't split ranking across duplicates. - OG images: an
opengraph-image.tsxper product, rendered withImageResponse, so shared links look good. - Status codes:
notFound()for deleted products, so they return a real 404 and not a soft 200.
The replace on the JSON-LD is there because product names come from the CMS. Escaping < stops a malicious name closing the script tag.
Interviewers look for: canonicals, real 404s, structured data and Core Web Vitals, not only "add a title".
32. Deploying
BasicWhat are your options for deploying a Next.js app?
So there are really three shapes: a managed platform like Vercel or Netlify, my own Node server or container, or a fully static export. The choice depends on which features I need and who runs the infrastructure.
| Option | How | Good for | Trade-off |
|---|---|---|---|
| Vercel / Netlify | Push to Git | Zero ops, previews per PR | Cost at scale, vendor features |
| Node server / Docker | next build + next start, or output: "standalone" | Existing infra, AWS/GCP, full control | I own caching, scaling, CDN |
| Static export | output: "export" | Docs, marketing, S3 + CDN | No SSR, actions, ISR or middleware |
// next.config.ts: small Docker images
export default { output: "standalone" };
# Dockerfile (runtime stage)
COPY .next/standalone ./
COPY .next/static ./.next/static
COPY public ./public
CMD ["node", "server.js"]
What standalone does is trace which files in node_modules are actually used and copy just those, so the image drops from over a gigabyte to around a hundred megabytes. The gotcha is that it doesn't copy public or .next/static, so I copy them myself or the site loads without CSS.
Interviewers look for: knowing which features need a server, and the standalone output for containers.
BasicHow do environment variables work in Next.js, and what does NEXT_PUBLIC_ mean?
So variables without the prefix only exist on the server. Variables with NEXT_PUBLIC_ are inlined into the client JavaScript at build time, so anyone can read them in the bundle.
# .env.local
DATABASE_URL=postgres://... # server only
STRIPE_SECRET_KEY=sk_live_... # server only
NEXT_PUBLIC_STRIPE_KEY=pk_live_... # publishable key: fine to expose
NEXT_PUBLIC_API_URL=https://api.shop.com
// in a Client Component
process.env.NEXT_PUBLIC_API_URL // replaced with the literal string at build
process.env.DATABASE_URL // undefined in the browser
The gotcha that bit my team: "at build time" is literal. We built one Docker image and promoted it from staging to production, but NEXT_PUBLIC_API_URL was frozen to the staging value, so production called the staging API. The fixes are to build per environment, or to read the value on the server and pass it down as a prop, since server code reads process.env at runtime for dynamic routes.
And the other mistake, adding NEXT_PUBLIC_ to a secret to "make it work" in a Client Component. That publishes it. If a client needs to use a secret, the call goes through a Server Action or Route Handler instead.
Interviewers look for: "inlined at build time", and the one-image-many-environments trap.
IntermediateWhat usually breaks when an app that works in next dev goes to production?
So dev and prod behave differently on purpose, and most surprises come from four places. That's why I always run next build && next start locally before shipping anything big.
- Static vs dynamic: in dev every page renders per request. In a build, a page that reads nothing dynamic is pre-rendered once, so "current time" or "latest posts" gets frozen.
- Build-time data: static pages call the API during the build. If the CI has no database access, the build fails, or worse, bakes in empty data.
- Missing env vars: set locally in
.env.localbut not on the host. - Type and lint errors: dev is forgiving, but the build fails on type errors.
Route (app) Size First Load JS
┌ ○ / 5.1 kB 110 kB
├ ƒ /dashboard 3.2 kB 108 kB
└ ● /blog/[slug] 1.4 kB 106 kB
○ Static ● SSG (generateStaticParams) ƒ Dynamic
That build summary is the first thing I read. In my last project the "latest news" homepage showed as ○ static, which was why editors' updates never appeared. We added revalidation by tag from the CMS webhook.
So in practice: read the build output, validate env vars at startup with something like a zod schema, and never trust dev caching behaviour.
IntermediateWhat do you have to think about when self-hosting Next.js on several instances?
So on Vercel a lot of this is handled for me. Self-hosted behind a load balancer, there are a few things that break because each instance has its own disk and memory.
- Shared cache: ISR and the Data Cache default to the local filesystem. Revalidate on instance A and instance B still serves stale pages. I configure a custom
cacheHandlerbacked by Redis. - Server Action encryption key: each build generates one. Instances built separately can't decrypt each other's closures, so I set
NEXT_SERVER_ACTIONS_ENCRYPTION_KEYto be the same everywhere. - Version skew: during a rolling deploy, an old browser tab can request a JS chunk that the new version doesn't have. Setting a
deploymentIdand keeping old static assets on the CDN for a while avoids broken navigations. - Image optimisation: CPU heavy, so I cache optimised images on a CDN or offload to an image service.
// next.config.ts
export default {
output: "standalone",
cacheHandler: require.resolve("./cache-handler.mjs"), // Redis-backed
cacheMaxMemorySize: 0, // don't keep a per-instance copy
deploymentId: process.env.GIT_SHA,
};
The one that bit us was the cache. The CMS webhook hit one pod, the others kept serving old content for hours, and it looked random to editors depending on which pod they landed on.
Interviewers look for: the shared-cache problem. It's the clearest sign someone has actually self-hosted.
IntermediateWhat features stop working with output: "export"?
So a static export produces plain HTML, CSS and JS files with no Node server behind them. Anything that needs code to run on the server per request stops working.
| Works | Doesn't work |
|---|---|
| Server Components (run at build time) | Server Actions |
generateStaticParams dynamic routes | Dynamic routes without it |
| Client-side fetching | cookies(), headers() |
| GET Route Handlers (emitted as files) | Middleware, rewrites, redirects in config |
next/image with a custom loader | Default image optimisation, ISR |
Let me give you an example. For our docs site, static export was perfect: every page known at build time, hosted on S3 with CloudFront, basically free. For the product dashboard it was the wrong choice, because it needs auth, per-user data and mutations.
The subtle point is that Server Components still work in an export. They just run once during next build instead of per request. So I can still query a CMS in a Server Component; the result is baked in until the next build.
AdvancedHow do you monitor a Next.js app in production and catch problems early?
So I want three signals: errors, performance from real users, and server traces. Next has hooks for all of them.
// instrumentation.ts: server errors + tracing
export async function register() {
if (process.env.NEXT_RUNTIME === "nodejs") await import("./sentry.server.config");
}
export async function onRequestError(err, request, context) {
// every uncaught server error: RSC render, route handler, action
reportError(err, { path: request.path, routeType: context.routeType });
}
// app/web-vitals.tsx: real-user Core Web Vitals
"use client";
import { useReportWebVitals } from "next/web-vitals";
export function WebVitals() {
useReportWebVitals(m => navigator.sendBeacon("/api/vitals", JSON.stringify(m)));
return null;
}
Then for the UI side, error.tsx per risky segment and a global-error.tsx so a crash in one widget doesn't take down the page, and so the error gets reported with the digest that links the client message to the server log. In production Next hides server error messages from the client, so the digest is how I find the real stack trace.
On process: preview deployments per PR, a smoke test against the preview, and one-click rollback. In my last project we also alerted on INP and LCP at the 75th percentile per route, which caught a regression from a new analytics script before customers complained.
Interviewers look for: instrumentation.ts, the error digest, and real-user metrics rather than only Lighthouse.
Predict & Spot the Bug
BasicWhat does this log? (render and effect order, parent vs child)
function Child() {
console.log("render Child");
useEffect(() => { console.log("effect Child"); });
return <p>child</p>;
}
function Parent() {
console.log("render Parent");
useEffect(() => { console.log("effect Parent"); });
return <Child />;
}
Answer: render Parent, render Child, effect Child, effect Parent
OK, so rendering goes top-down: React calls Parent, sees it returns <Child />, then calls Child. That's why the render logs are parent first.
Effects are the opposite. React runs them after the commit, bottom-up, children before parents. The way I remember it is that a parent's effect can rely on its children already being mounted and set up, the same way a DOM ref to a child element is ready in the parent's effect.
Without StrictMode that's the whole output. In development with StrictMode, each render log appears twice, and the effects run, clean up, and run again.
Interviewers look for: "render top-down, effects bottom-up". The same applies to useLayoutEffect.
BasicWhat does the count show after one click? (batching)
function Counter() {
const [count, setCount] = useState(0);
console.log("render", count);
function addThree() {
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
}
function addThreeFixed() {
setCount(c => c + 1);
setCount(c => c + 1);
setCount(c => c + 1);
}
// button A calls addThree, button B calls addThreeFixed
}
Answer: clicking A logs render 1. Then clicking B logs render 4. One render per click.
So in addThree, count is a constant for this render: it's 0. All three calls say "set it to 0 + 1", so the result is 1. They don't see each other's updates because the state variable doesn't change until the next render.
Then React batches all three updates into a single re-render, which is why there's one log per click, not three. Since React 18 that batching happens everywhere, including in setTimeout and promise callbacks.
With the updater form, React queues functions and runs each with the result of the previous one: 1 to 2, 2 to 3, 3 to 4. So whenever the next state depends on the previous one, I use the updater form.
BasicWhat does this log in development? (StrictMode double effects)
function Chat() {
console.log("render");
useEffect(() => {
console.log("connect");
return () => console.log("disconnect");
}, []);
return null;
}
root.render(<StrictMode><Chat /></StrictMode>);
Answer: render, render, connect, disconnect, connect. In production: render, connect.
So in development StrictMode does two extra things on purpose. It renders the component twice to catch impure render logic, and it mounts, unmounts and remounts it once to check that my effect cleans up properly. (React 19 dims the second render's logs in the console, so it's easy to miss.)
What people do wrong is "fix" this with a useRef flag to skip the second run. That hides the real problem. If my effect breaks when run twice, it will also break when the user navigates away and back, or with Fast Refresh.
The correct fix is a symmetric cleanup: whatever the effect opens, the cleanup closes. Here that's already done, so connect, disconnect, connect is harmless and I end up with exactly one live connection.
Interviewers look for: "it's dev only, and it's testing my cleanup", not "React has a bug".
BasicThis list doesn't update when I add an item. Find the bug. (mutating state)
function TodoList() {
const [todos, setTodos] = useState([]);
function add(text) {
todos.push({ id: crypto.randomUUID(), text });
setTodos(todos);
}
// ...
}
Answer: nothing re-renders. The array is mutated in place, and setTodos is given the same reference.
So React compares old and new state with Object.is. Here it's the same array object, so React assumes nothing changed and bails out. The data is actually changed, which makes it worse: the next unrelated re-render suddenly shows the item, so it looks random.
The fix is to create a new array:
function add(text) {
setTodos(prev => [...prev, { id: crypto.randomUUID(), text }]);
}
// same idea for update and remove
setTodos(prev => prev.map(t => t.id === id ? { ...t, done: !t.done } : t));
setTodos(prev => prev.filter(t => t.id !== id));
The same bug hides in nested objects: user.address.city = "X"; setUser({ ...user }) creates a new top-level object, but address is still the old one, so a memoised child that receives address won't update. For deeply nested state I use Immer, or I flatten the state.
IntermediateWhat does this log when roomId changes from 1 to 2? (cleanup order)
function Room({ roomId }) {
useEffect(() => {
console.log("subscribe", roomId);
return () => console.log("unsubscribe", roomId);
}, [roomId]);
return <h1>Room {roomId}</h1>;
}
Answer: on mount subscribe 1. When the prop changes: unsubscribe 1, then subscribe 2.
So when the dependency changes, React first runs the previous effect's cleanup, then the new effect. The key detail is that the cleanup logs 1, not 2. The cleanup is a closure created during the render where roomId was 1, so it remembers that value. That's exactly what I want: unsubscribe from the old room, not the new one.
Also the order relative to the screen: the new <h1>Room 2</h1> is committed and usually painted before either of these runs, because useEffect runs after the browser paints. If I needed the cleanup to happen before paint, like measuring the DOM, I'd use useLayoutEffect.
And on unmount, only unsubscribe 2 runs.
IntermediateWhy is this timer stuck at 1? (stale closure)
function Timer() {
const [seconds, setSeconds] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setSeconds(seconds + 1);
}, 1000);
return () => clearInterval(id);
}, []);
return <p>{seconds}s</p>;
}
Answer: it shows 1s after the first tick and stays at 1 forever.
So the effect runs once, because the dependency array is empty, and the interval callback closes over seconds from that first render, which is 0. Every tick calls setSeconds(0 + 1). React sees 1 again and nothing changes after the first tick.
This is the missing-dependency bug. The linter rule react-hooks/exhaustive-deps flags it. Adding seconds to the deps would work but tear down and recreate the interval every second. The cleaner fix is to not read seconds at all:
useEffect(() => {
const id = setInterval(() => {
setSeconds(s => s + 1); // always the latest value
}, 1000);
return () => clearInterval(id);
}, []);
For cases where the callback needs to read several values, like a prop and state, I keep the latest callback in a ref, or in React 19.2 use useEffectEvent.
Interviewers look for: naming it a stale closure and choosing the updater function over adding the dependency.
IntermediateWhat happens to the counter when user changes? (key reset)
function Counter({ name }) {
const [clicks, setClicks] = useState(0);
return <button onClick={() => setClicks(c => c + 1)}>{name}: {clicks}</button>;
}
function App() {
const [user, setUser] = useState("alice");
return (
<>
<Counter key={user} name={user} />
<button onClick={() => setUser("bob")}>Switch</button>
</>
);
}
// click the counter twice, then click Switch
Answer: it shows alice: 2, then after Switch bob: 0. Alice's counter unmounts and a new one mounts.
So React decides whether to keep a component's state by its position in the tree and its key. Same type, same position, same key: it keeps the state and just updates props. Different key: it treats it as a different component, runs the old one's cleanups, and mounts a fresh one with fresh state.
If I remove key={user}, it shows bob: 2, because the position and type haven't changed, so the state survives. That's a real bug in forms: switching between two users' profiles kept the first user's half-typed input.
So in practice I use a key as a deliberate "reset everything when this changes" switch. It's simpler and more correct than an effect that resets each piece of state manually.
IntermediateDeleting the first row shows the wrong text in the inputs. Find the bug. (index keys)
function Guests() {
const [guests, setGuests] = useState(["Ana", "Ben", "Cy"]);
return guests.map((name, i) => (
<div key={i}>
{name} <input placeholder="dietary notes" />
<button onClick={() => setGuests(g => g.filter((_, j) => j !== i))}>x</button>
</div>
));
}
// type "vegan" in Ana's input, then delete Ana
Answer: the list shows Ben and Cy, but "vegan" is now in Ben's input.
So the key is the array index. Before the delete, Ana is key 0, Ben key 1, Cy key 2. After it, Ben is key 0 and Cy key 1. React matches by key, so it thinks key 0 is the same component with a new name, and key 2 was removed. It updates the text, but the uncontrolled input's DOM state, "vegan", stays with key 0, which is now Ben.
The fix is a stable ID from the data:
const [guests, setGuests] = useState([
{ id: "g1", name: "Ana" }, { id: "g2", name: "Ben" }, { id: "g3", name: "Cy" },
]);
guests.map(g => <div key={g.id}>…</div>);
Index keys are fine only when the list never reorders, filters or deletes, and items have no state. I avoid generating the key with Math.random() during render too, because that remounts every row on every render.
Interviewers look for: explaining that state and DOM follow the key, not the data.
IntermediateThis component freezes the browser. Find the bug. (infinite effect loop)
function Products({ category }) {
const [products, setProducts] = useState([]);
const filters = { category, inStock: true };
useEffect(() => {
fetchProducts(filters).then(setProducts);
}, [filters]);
return <Grid items={products} />;
}
Answer: it fetches in an endless loop.
So filters is a new object on every render. React compares deps with Object.is, and a new object is never equal to the old one. So: the effect runs, fetches, calls setProducts with a new array, which re-renders, which creates a new filters, which runs the effect again. Forever.
The fix is to depend on primitives, and build the object inside the effect. I also add a cleanup so a slow old response can't overwrite a newer one:
useEffect(() => {
let ignore = false;
fetchProducts({ category, inStock: true }).then(data => {
if (!ignore) setProducts(data);
});
return () => { ignore = true; };
}, [category]);
Other versions of this bug: calling setState in an effect with no dependency array, or depending on a function defined in the component body. useMemo or useCallback can stabilise them, but moving the value inside the effect is simpler. And in a real app, I'd use TanStack Query or fetch on the server rather than a hand-written effect.
IntermediateThe name doesn't update when the parent passes a new user. Find the bug. (derived state)
function ProfileCard({ user }) {
const [fullName, setFullName] = useState(user.first + " " + user.last);
return <h2>{fullName}</h2>;
}
// parent changes `user` from Ana Lee to Ben Kay
Answer: it keeps showing "Ana Lee".
So the argument to useState is only the initial value. It's read on the first render and ignored after that. The prop changed, but the state is a stale copy.
The fix is to not store it at all. It's derived from props, so I compute it during render:
function ProfileCard({ user }) {
const fullName = `${user.first} ${user.last}`; // always in sync
return <h2>{fullName}</h2>;
}
What people often reach for is a useEffect that calls setFullName when user changes. That works, but it renders once with the stale value, then again with the right one, so there's a flash and an extra render. If the computation is expensive, useMemo; if the state really is editable and seeded from a prop, like a form draft, I reset it with key={user.id} on the parent side.
Interviewers look for: "if it can be computed from props or state, don't put it in state".
AdvancedNext.js warns about a hydration mismatch. Find the bug.
"use client";
export function Greeting() {
const hour = new Date().getHours();
const isMobile = typeof window !== "undefined" && window.innerWidth < 768;
return (
<p>
{hour < 12 ? "Good morning" : "Good evening"}
{isMobile ? " (mobile)" : ""}
</p>
);
}
Answer: two mismatches. The server's hour, often in UTC, can differ from the browser's, and isMobile is always false on the server but can be true in the browser.
So hydration expects the first client render to produce exactly the HTML the server sent. Anything that depends on the environment, like time, timezone, window, localStorage or Math.random(), breaks that. React then throws away the server HTML for that part and re-renders on the client, so I lose the SSR benefit and get a flicker.
The fix is to render the same thing on both sides first, then update after mount:
"use client";
export function Greeting() {
const [client, setClient] = useState(null);
useEffect(() => {
setClient({
hour: new Date().getHours(),
isMobile: window.matchMedia("(max-width: 767px)").matches,
});
}, []);
if (!client) return <p>Hello</p>; // same on server and first client render
return <p>{client.hour < 12 ? "Good morning" : "Good evening"}{client.isMobile ? " (mobile)" : ""}</p>;
}
Other options depending on the case: CSS media queries instead of JS for layout differences, dynamic(() => import(...), { ssr: false }) for a widget that is purely client-side, or suppressHydrationWarning on a single element like a timestamp where I accept the difference. Browser extensions that inject attributes also trigger this warning, so I test in a clean profile before chasing a ghost.
Interviewers look for: the rule "first client render must match the server", and knowing the useEffect pattern.
AdvancedWhat does this log on mount? (useLayoutEffect vs useEffect order)
function Child() {
useLayoutEffect(() => { console.log("layout Child"); });
useEffect(() => { console.log("effect Child"); });
return null;
}
function Parent() {
useLayoutEffect(() => { console.log("layout Parent"); });
useEffect(() => { console.log("effect Parent"); });
return <Child />;
}
Answer: layout Child, layout Parent, effect Child, effect Parent
So there are two separate phases after the commit. All layout effects run first, synchronously, right after React has updated the DOM and before the browser paints. Then, after paint, all passive effects, the useEffects, run. Within each phase it's children before parents.
That's why I use useLayoutEffect for measuring: say a tooltip needs the button's position. If I measured in useEffect, the user would see the tooltip in the wrong spot for one frame, then jump. In the layout effect I measure and set state before anything is painted.
The cost is that layout effects block painting, so I keep them tiny. And they don't run during server rendering, so for SSR the pattern is to render a neutral first state and measure on the client.
Machine Coding
BasicBuild a useDebounce hook.
OK, so the idea is: keep a debounced copy of the value, and only update it once the value has stopped changing for delay ms. Every change restarts a timer, and the effect cleanup is what cancels the previous timer.
import { useEffect, useState } from "react";
export function useDebounce<T>(value: T, delay = 300): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
// schedule the update...
const id = setTimeout(() => setDebounced(value), delay);
// ...and cancel it if value changes again before it fires
return () => clearTimeout(id);
}, [value, delay]);
return debounced;
}
// usage: search as you type
function Search() {
const [query, setQuery] = useState("");
const debouncedQuery = useDebounce(query, 400);
const results = useSearch(debouncedQuery); // only fires after typing pauses
return <input value={query} onChange={e => setQuery(e.target.value)} />;
}
What I like about this version is that the input itself stays controlled and instant. Only the expensive thing downstream is debounced.
Edge cases I've handled: unmounting mid-delay is covered by the cleanup, so no state update on an unmounted component. Changing delay restarts the timer. The first render returns the initial value immediately, not after a delay.
If the interviewer wants a debounced callback instead of a value, I'd keep the timer in a useRef, keep the latest callback in another ref so it isn't stale, and clear the timer on unmount.
BasicBuild a star rating component.
OK, so the semantic way to build this is a group of radio buttons styled as stars. That gives me keyboard support with the arrow keys and correct screen reader announcements with no extra work. Then I add a hover preview on top.
function StarRating({ value, onChange, max = 5, name = "rating" }) {
const [hover, setHover] = useState(0);
const shown = hover || value; // preview while hovering
return (
<fieldset className="stars" onMouseLeave={() => setHover(0)}>
<legend className="visually-hidden">Rating</legend>
{Array.from({ length: max }, (_, i) => {
const star = i + 1;
return (
<label key={star} onMouseEnter={() => setHover(star)}>
<input
type="radio"
name={name}
value={star}
checked={value === star}
onChange={() => onChange(star)}
className="visually-hidden"
/>
<span aria-hidden="true" className={star <= shown ? "star filled" : "star"}>{"\u2605"}</span>
<span className="visually-hidden">{star} star{star > 1 ? "s" : ""}</span>
</label>
);
})}
</fieldset>
);
}
What happens here: the actual radio inputs are visually hidden but still focusable, so Tab lands on the group and the arrow keys change the rating natively. The star glyph is aria-hidden, and the hidden text says "3 stars", so a screen reader hears "3 stars, radio button, 3 of 5". It's controlled, so the parent owns the value.
Edge cases: hover preview resets on mouse leave, max is configurable, and the name prop avoids clashes if two ratings are on one page. If they ask for half stars, I'd use ten radios with half-width labels. For a read-only display, I'd render a single role="img" with aria-label="Rated 4 out of 5" instead of inputs.
IntermediateBuild a useFetch hook that handles race conditions and cancellation.
OK, so the two real problems are: responses arriving out of order when the URL changes quickly, and requests still running after unmount. AbortController solves both. The cleanup aborts the old request, so its response can never overwrite the new one.
import { useEffect, useState } from "react";
type State<T> =
| { status: "idle" | "loading"; data?: T; error?: undefined }
| { status: "success"; data: T; error?: undefined }
| { status: "error"; data?: T; error: Error };
export function useFetch<T>(url: string | null) {
const [state, setState] = useState<State<T>>({ status: "idle" });
useEffect(() => {
if (!url) return; // allow "don't fetch yet"
const controller = new AbortController();
setState(s => ({ status: "loading", data: s.data })); // keep old data visible
fetch(url, { signal: controller.signal })
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`); // fetch doesn't reject on 4xx/5xx
return res.json() as Promise<T>;
})
.then(data => setState({ status: "success", data }))
.catch(err => {
if (err.name === "AbortError") return; // we cancelled it: not an error
setState(s => ({ status: "error", data: s.data, error: err }));
});
return () => controller.abort(); // new url or unmount
}, [url]);
return state;
}
Walking through the race: user types "re", then "rea". The "re" request is aborted when the URL changes, so even if its server reply is slower, the promise rejects with AbortError and I ignore it. Only "rea" can set state.
Edge cases covered: non-2xx status as an error, a null URL, keeping previous data during loading so the UI doesn't flash empty, and StrictMode's double effect, which just aborts the first request.
In production I'd use TanStack Query for caching, dedupe and retries, but this is exactly what it does underneath.
Interviewers look for: abort in the cleanup, ignoring AbortError, and checking res.ok.
IntermediateBuild a useLocalStorage hook.
OK, so it should behave like useState but persist. The things that make it non-trivial: SSR, where localStorage doesn't exist; bad JSON; storage being blocked; and staying in sync across tabs.
import { useCallback, useEffect, useState } from "react";
export function useLocalStorage<T>(key: string, initial: T) {
// start with `initial` so the server and first client render match
const [value, setValue] = useState<T>(initial);
// read after mount: avoids hydration mismatch
useEffect(() => {
try {
const raw = localStorage.getItem(key);
if (raw !== null) setValue(JSON.parse(raw));
} catch { /* blocked storage or corrupt JSON: keep initial */ }
}, [key]);
// same API as setState, including the updater form
const set = useCallback((next: T | ((prev: T) => T)) => {
setValue(prev => {
const resolved = next instanceof Function ? next(prev) : next;
try { localStorage.setItem(key, JSON.stringify(resolved)); } catch { /* quota */ }
return resolved;
});
}, [key]);
// other tabs changed it
useEffect(() => {
function onStorage(e: StorageEvent) {
if (e.key !== key) return;
try { setValue(e.newValue === null ? initial : JSON.parse(e.newValue)); } catch {}
}
window.addEventListener("storage", onStorage);
return () => window.removeEventListener("storage", onStorage);
}, [key]); // eslint-disable-line react-hooks/exhaustive-deps
return [value, set] as const;
}
The trade-off I made is reading in an effect, so there's one render with the initial value. In a client-only app I could read lazily in useState(() => ...) and skip that, but in Next.js that would cause a hydration mismatch. The storage event only fires in other tabs, which is exactly what I want for sync.
Edge cases handled: private mode and quota errors, corrupt values, updater functions, and the key changing.
IntermediateBuild infinite scroll with IntersectionObserver.
OK, so I put an invisible sentinel element after the last item and watch it with an IntersectionObserver. When it comes near the viewport, I load the next page. That's much cheaper than a scroll listener doing maths on every scroll event.
function Feed() {
const [items, setItems] = useState([]);
const [cursor, setCursor] = useState(null);
const [hasMore, setHasMore] = useState(true);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const sentinelRef = useRef(null);
const loadingRef = useRef(false); // sync guard against double-firing
const loadMore = useCallback(async () => {
if (loadingRef.current || !hasMore) return;
loadingRef.current = true;
setLoading(true);
setError(null);
try {
const page = await fetchPosts({ cursor }); // { items, nextCursor }
setItems(prev => [...prev, ...page.items]);
setCursor(page.nextCursor);
setHasMore(page.nextCursor !== null);
} catch (e) {
setError(e); // stop; user can retry
} finally {
loadingRef.current = false;
setLoading(false);
}
}, [cursor, hasMore]);
useEffect(() => {
const node = sentinelRef.current;
if (!node || error) return;
const observer = new IntersectionObserver(
entries => { if (entries[0].isIntersecting) loadMore(); },
{ rootMargin: "400px" } // start loading before the user hits the end
);
observer.observe(node);
return () => observer.disconnect(); // re-created with the fresh loadMore
}, [loadMore, error]);
return (
<>
<ul>{items.map(p => <li key={p.id}>{p.title}</li>)}</ul>
{loading && <p aria-live="polite">Loading…</p>}
{error && <button onClick={loadMore}>Retry</button>}
{hasMore ? <div ref={sentinelRef} /> : <p>You're all caught up</p>}
</>
);
}
The ref guard is there because the observer can fire twice before the loading state re-renders, and I'd fetch the same page twice. I use a cursor rather than page numbers, so new posts inserted at the top don't cause duplicates.
Edge cases: a short first page where the sentinel is visible straight away (the observer fires immediately, so it keeps loading), errors stop the loop and show retry, and the end of the list. For thousands of items I'd add virtualisation, like TanStack Virtual, so the DOM stays small.
IntermediateBuild a todo list with filters using useReducer.
OK, so I'll keep the list and the filter in one reducer, since every action is a clear event. The visible list is derived during render, not stored, so it can never get out of sync.
const initialState = { todos: [], filter: "all" }; // "all" | "active" | "done"
function reducer(state, action) {
switch (action.type) {
case "added":
return { ...state, todos: [...state.todos, { id: crypto.randomUUID(), text: action.text, done: false }] };
case "toggled":
return { ...state, todos: state.todos.map(t => t.id === action.id ? { ...t, done: !t.done } : t) };
case "deleted":
return { ...state, todos: state.todos.filter(t => t.id !== action.id) };
case "clearedDone":
return { ...state, todos: state.todos.filter(t => !t.done) };
case "filterChanged":
return { ...state, filter: action.filter };
default:
throw new Error(`Unknown action: ${action.type}`);
}
}
function Todos() {
const [state, dispatch] = useReducer(reducer, initialState);
const [text, setText] = useState("");
// derived, not stored
const visible = state.todos.filter(t =>
state.filter === "all" ? true : state.filter === "done" ? t.done : !t.done
);
const left = state.todos.filter(t => !t.done).length;
return (
<>
<form onSubmit={e => {
e.preventDefault();
if (!text.trim()) return; // ignore empty
dispatch({ type: "added", text: text.trim() });
setText("");
}}>
<input value={text} onChange={e => setText(e.target.value)} aria-label="New todo" />
</form>
<ul>
{visible.map(t => (
<li key={t.id}>
<label>
<input type="checkbox" checked={t.done}
onChange={() => dispatch({ type: "toggled", id: t.id })} />
{t.text}
</label>
<button aria-label={`Delete ${t.text}`} onClick={() => dispatch({ type: "deleted", id: t.id })}>x</button>
</li>
))}
</ul>
<p>{left} item{left === 1 ? "" : "s"} left</p>
{["all", "active", "done"].map(f => (
<button key={f} aria-pressed={state.filter === f}
onClick={() => dispatch({ type: "filterChanged", filter: f })}>{f}</button>
))}
<button onClick={() => dispatch({ type: "clearedDone" })}>Clear completed</button>
</>
);
}
The reason I pick useReducer here is that the update logic lives in one pure function I can unit-test without rendering anything. Actions are named as events, "added" and "toggled", which reads well in DevTools.
Edge cases: empty and whitespace input, stable IDs as keys, immutable updates everywhere, pluralisation, and the unknown-action throw catches typos. Next steps I'd mention: persist with the useLocalStorage idea, and put the filter in the URL so it survives a refresh.
AdvancedBuild an accessible modal with a focus trap.
OK, so first I'd say the modern answer is the native <dialog> element with showModal(). It gives me the focus trap, Escape to close, the backdrop and making the rest of the page inert for free. So let me write it that way, then mention what I'd add by hand if I had to support a custom div.
import { useEffect, useRef } from "react";
export function Modal({ open, onClose, title, children }) {
const dialogRef = useRef(null);
const returnFocusRef = useRef(null);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (open && !dialog.open) {
returnFocusRef.current = document.activeElement; // remember the trigger
dialog.showModal(); // traps focus, page becomes inert
} else if (!open && dialog.open) {
dialog.close();
}
}, [open]);
return (
<dialog
ref={dialogRef}
aria-labelledby="modal-title"
onClose={() => { // fires on Escape and on close()
onClose();
returnFocusRef.current?.focus(); // give focus back to the trigger
}}
onClick={e => { // click on the backdrop closes
if (e.target === dialogRef.current) dialogRef.current.close();
}}
>
<h2 id="modal-title">{title}</h2>
{children}
<button autoFocus onClick={() => dialogRef.current.close()}>Close</button>
</dialog>
);
}
What happens here: showModal() puts it in the top layer, so no z-index fights, and the browser keeps Tab inside it. onClose fires for Escape too, so the parent's state stays in sync. I restore focus to the element that opened it, which screen reader users rely on.
If I had to build it from a div, I'd add: role="dialog" and aria-modal="true"; a keydown handler that finds focusable elements and wraps Tab from the last to the first, and Shift+Tab the other way; Escape to close; inert on the app root; and scroll lock on the body. I'd render it in a portal to escape overflow: hidden parents.
Interviewers look for: focus goes in, stays in, and returns to the trigger; Escape closes; the title is labelled.
AdvancedBuild an autocomplete / typeahead.
OK, so I'll break it into three parts: debounced fetching with race handling, keyboard navigation, and the ARIA combobox pattern so screen readers announce it properly. I'll reuse the useDebounce hook from before.
function Autocomplete({ onSelect }) {
const [query, setQuery] = useState("");
const [results, setResults] = useState([]);
const [open, setOpen] = useState(false);
const [active, setActive] = useState(-1); // highlighted option index
const debounced = useDebounce(query.trim(), 250);
useEffect(() => {
if (debounced.length < 2) { setResults([]); return; }
const controller = new AbortController();
fetch(`/api/cities?q=${encodeURIComponent(debounced)}`, { signal: controller.signal })
.then(r => r.json())
.then(data => { setResults(data); setActive(-1); setOpen(true); })
.catch(e => { if (e.name !== "AbortError") setResults([]); });
return () => controller.abort(); // stale response can't win
}, [debounced]);
function choose(item) {
setQuery(item.name);
setOpen(false);
onSelect(item);
}
function onKeyDown(e) {
if (!open || results.length === 0) return;
if (e.key === "ArrowDown") { e.preventDefault(); setActive(i => (i + 1) % results.length); }
if (e.key === "ArrowUp") { e.preventDefault(); setActive(i => (i - 1 + results.length) % results.length); }
if (e.key === "Enter" && active >= 0) { e.preventDefault(); choose(results[active]); }
if (e.key === "Escape") setOpen(false);
}
return (
<div className="combo">
<input
role="combobox"
aria-expanded={open}
aria-controls="city-list"
aria-autocomplete="list"
aria-activedescendant={active >= 0 ? `city-${results[active].id}` : undefined}
value={query}
onChange={e => setQuery(e.target.value)}
onKeyDown={onKeyDown}
onBlur={() => setOpen(false)}
/>
{open && results.length > 0 && (
<ul id="city-list" role="listbox">
{results.map((c, i) => (
<li
key={c.id}
id={`city-${c.id}`}
role="option"
aria-selected={i === active}
onMouseDown={e => { e.preventDefault(); choose(c); }} // before blur
>{c.name}</li>
))}
</ul>
)}
</div>
);
}
A few decisions worth calling out. Focus stays in the input the whole time, and aria-activedescendant tells assistive tech which option is highlighted. I use onMouseDown with preventDefault on options, because with onClick the input's blur would close the list before the click registers. That's a bug I've actually shipped once.
Edge cases: minimum query length, encoding the query, aborting stale requests, wrapping arrow keys, and Escape. With more time I'd add a small in-memory cache keyed by query, highlight the matched substring, and an aria-live region announcing "5 results".
AdvancedBuild Tabs as compound components.
OK, so compound components means an API like <Tabs><Tabs.List><Tabs.Tab> where the pieces share state through context instead of props. The user of the component gets full control over the markup, and I handle the state and accessibility.
import { createContext, use, useId, useRef, useState } from "react";
const TabsContext = createContext(null);
function useTabs() {
const ctx = use(TabsContext);
if (!ctx) throw new Error("Tabs.* must be used inside <Tabs>");
return ctx;
}
export function Tabs({ defaultValue, value, onChange, children }) {
const [inner, setInner] = useState(defaultValue);
const selected = value ?? inner; // controlled or uncontrolled
const select = v => { setInner(v); onChange?.(v); };
const baseId = useId(); // unique ids for aria wiring
return <TabsContext value={{ selected, select, baseId }}>{children}</TabsContext>;
}
function List({ children, label }) {
const ref = useRef(null);
function onKeyDown(e) { // roving focus with arrows
const tabs = [...ref.current.querySelectorAll('[role="tab"]')];
const i = tabs.indexOf(document.activeElement);
let next = null;
if (e.key === "ArrowRight") next = tabs[(i + 1) % tabs.length];
if (e.key === "ArrowLeft") next = tabs[(i - 1 + tabs.length) % tabs.length];
if (e.key === "Home") next = tabs[0];
if (e.key === "End") next = tabs[tabs.length - 1];
if (next) { e.preventDefault(); next.focus(); next.click(); }
}
return <div role="tablist" aria-label={label} ref={ref} onKeyDown={onKeyDown}>{children}</div>;
}
function Tab({ value, children }) {
const { selected, select, baseId } = useTabs();
const isSelected = selected === value;
return (
<button role="tab" id={`${baseId}-tab-${value}`}
aria-selected={isSelected} aria-controls={`${baseId}-panel-${value}`}
tabIndex={isSelected ? 0 : -1} // only the active tab is in the Tab order
onClick={() => select(value)}>{children}</button>
);
}
function Panel({ value, children }) {
const { selected, baseId } = useTabs();
if (selected !== value) return null;
return (
<div role="tabpanel" id={`${baseId}-panel-${value}`}
aria-labelledby={`${baseId}-tab-${value}`} tabIndex={0}>{children}</div>
);
}
Tabs.List = List; Tabs.Tab = Tab; Tabs.Panel = Panel;
// usage
<Tabs defaultValue="specs">
<Tabs.List label="Product details">
<Tabs.Tab value="specs">Specs</Tabs.Tab>
<Tabs.Tab value="reviews">Reviews</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="specs">…</Tabs.Panel>
<Tabs.Panel value="reviews">…</Tabs.Panel>
</Tabs>
What happens: Tabs owns the state and exposes it through context, using React 19's use and <Context> as a provider. It supports both controlled and uncontrolled usage. useId gives SSR-safe IDs, so two tab groups on a page don't clash. Keyboard handling follows the WAI-ARIA tabs pattern: one tab stop for the group, arrows to move.
Edge cases: a helpful error if a piece is used outside Tabs, Home and End keys, and wrap-around. The trade-off I'd mention: unmounting inactive panels loses their state. If a panel holds a form, I'd render all panels and use hidden instead.
AdvancedBuild a Next.js form with a Server Action, validation and a pending state.
OK, so let me split it the way I would in the app: a server file with the schema and the action, and a small client form that uses useActionState for errors and pending. Validation runs on the server because that's the one that counts; I add HTML attributes for quick client feedback.
// app/contact/actions.ts
"use server";
import { z } from "zod";
import { redirect } from "next/navigation";
const schema = z.object({
name: z.string().trim().min(2, "Name is too short"),
email: z.string().trim().email("Enter a valid email"),
message: z.string().trim().min(10, "Tell us a bit more").max(2000),
});
export type FormState = {
errors?: Partial<Record<"name" | "email" | "message" | "form", string[]>>;
values?: Record<string, string>;
};
export async function sendMessage(_prev: FormState, formData: FormData): Promise<FormState> {
const values = Object.fromEntries(formData) as Record<string, string>;
const parsed = schema.safeParse(values);
if (!parsed.success) {
// return errors AND values, so the form keeps what the user typed
return { errors: parsed.error.flatten().fieldErrors, values };
}
try {
await db.message.create({ data: parsed.data });
} catch {
return { errors: { form: ["Something went wrong. Please try again."] }, values };
}
redirect("/contact/thanks"); // outside try/catch: redirect works by throwing
}
// app/contact/ContactForm.tsx
"use client";
import { useActionState } from "react";
import { sendMessage, type FormState } from "./actions";
export function ContactForm() {
const [state, action, pending] = useActionState<FormState, FormData>(sendMessage, {});
const err = (f: "name" | "email" | "message") => state.errors?.[f]?.[0];
return (
<form action={action} noValidate>
<label htmlFor="name">Name</label>
<input id="name" name="name" required defaultValue={state.values?.name}
aria-invalid={!!err("name")} aria-describedby="name-err" />
<p id="name-err" role="alert">{err("name")}</p>
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" required defaultValue={state.values?.email}
aria-invalid={!!err("email")} aria-describedby="email-err" />
<p id="email-err" role="alert">{err("email")}</p>
<label htmlFor="message">Message</label>
<textarea id="message" name="message" required defaultValue={state.values?.message}
aria-invalid={!!err("message")} aria-describedby="message-err" />
<p id="message-err" role="alert">{err("message")}</p>
{state.errors?.form && <p role="alert">{state.errors.form[0]}</p>}
<button disabled={pending}>{pending ? "Sending…" : "Send"}</button>
</form>
);
}
Walking through it: the action receives the previous state first because of useActionState. On a validation failure I return the errors and the submitted values, since React 19 resets uncontrolled fields after the action, so defaultValue puts the text back. The redirect sits outside the try, otherwise my catch would swallow it. pending disables the button, which also prevents double submits.
Edge cases handled: trimming, server-side validation even if someone bypasses the form, a generic form-level error for database failures, accessible error wiring with aria-invalid and aria-describedby, and it still works without JavaScript because it's a real form action. For a real contact form I'd add rate limiting and a honeypot field in the action, since it's a public endpoint.
Interviewers look for: server-side validation, returning values on error, redirect outside try/catch, and a real pending state.