Skip to content
Tech Interview Prep home
Technical interview guide

React Fundamentals

The rendering model, state as a snapshot, keys and component identity, effects as synchronization, memoisation, and the client boundary.

Read
44 min
Practice MCQs
25
Interview QA
25
Edition
v3
Editorial status
Reviewed
Relevant for
Frontend Engineer

Scope: React 19, including automatic batching from React 18 and the Server Component model as implemented by React 19 and Next.js 16.

Overview

Curated: · Written: · Reviewed:

React fundamentals

React interviews are rarely about API surface; they are about the model underneath it, because almost every bug a candidate has fixed comes from a wrong belief about when React runs their code. This guide works through that model: render as a pure calculation and commit as the separate write, state as a snapshot that an event handler cannot change mid-flight, the queue that makes updater functions compose, the identity rules that decide when state is preserved or thrown away, effects as synchronization with the world outside React rather than as lifecycle hooks, the memoisation trade and when it is worth paying, and the boundaries — error, Suspense, and the client boundary — that decide where behaviour starts. Each lesson states the rule, the mechanism, the misconception an interviewer is listening for, and the practice that follows, with a short example.

render as a calculation, commit as the write

A render is React calling your components to compute what the UI should look like, and the commit is the separate step where React applies the difference to the DOM.

Treat the render phase as a pure calculation: same props and state must produce the same output, with no DOM reads, no network calls, and no mutation of anything outside the component. React may call a component more than once for a single update, and it skips the commit entirely when the computed output has not changed.

The same tracking call in two places: 2 render attempts under StrictMode send 2 events for 1 user action.

function Bad({ id }) {
  analytics.track("viewed", id); // runs on every render attempt
  return <span>{id}</span>;
}
function Good({ id }) {
  useEffect(() => { analytics.track("viewed", id); }, [id]); // once per id
  return <span>{id}</span>;
}

Interview trap. Putting a side effect directly in the component body makes it run on every render attempt, including the extra ones React performs, so the number of requests or writes is not the number of user actions.

Engineering practice. Keep the body a calculation, move writes to event handlers when they are caused by an interaction, and use an effect only to synchronize with something outside React. In development the count is doubled — StrictMode calls the body 2 times per update — so 1 user action runs anything written directly in the body 2 times.

components as pure functions of props and state

A component must return the same JSX for the same props, state, and context, which is what lets React re-run it whenever it likes and discard the result.

Do not mutate variables declared outside the component, objects received in props, or values from a previous render. Build new objects and arrays inside the render, and let anything that must vary come in as props, state, or context.

One module variable read by two components: 2 renders of the same props return 2 different answers.

let count = 0;                      // outside the render
function Impure() { count += 1; return <p>{count}</p>; }   // output depends on call count
function Pure({ count }) { return <p>{count}</p>; }        // output depends on props

Interview trap. Mutating a prop or a module-level variable during render produces output that depends on how many times React happened to call the component, which shows as a bug that only appears in development or only after a fast refresh.

Engineering practice. Write renders that touch only their own locals, keep shared mutable state in a store with an official subscription, and let the linter's purity rules fail the build rather than debating them.

props as a read-only argument

Props are the arguments of a render and belong to the parent that passed them, so a child changes what it displays by asking the parent to re-render with different values.

Mutating a prop object changes the parent's data without scheduling a render, so the screen and the data disagree until something else re-renders. Pass a callback down when the child needs to change the value, and keep the state where both the reader and the writer can reach it.

A prop copied into state stops following the parent: 3 parent updates later, the child still shows the 1st value.

function Frozen({ name }) {
  const [value, setValue] = useState(name); // only the first name is ever used
  return <input value={value} onChange={(e) => setValue(e.target.value)} />;
}
function Lifted({ name, onChange }) {
  return <input value={name} onChange={(e) => onChange(e.target.value)} />;
}

Interview trap. Copying a prop into state to make it editable freezes the initial value, so later updates from the parent are ignored until the component is remounted.

Engineering practice. Lift the state to the closest common parent when two components need it, pass an updater callback down, and reserve prop-initialised state for values that genuinely are only a starting point. The failure is silent: 3 mutations of a prop object schedule 0 renders, so the data has moved 3 times while the screen still shows the 1st value.

state as a snapshot of one render

A state variable is a constant for the render it belongs to: calling a setter schedules a new render rather than changing the value already captured by the running one.

An event handler closes over the values from the render that created it, so reading the state variable after calling its setter returns the old value. The new value is visible in the next render, which is where the updated UI is computed.

Three increments, one step: 3 calls against the same snapshot move the count by 1, not 3.

const [n, setN] = useState(0);
function onClick() {
  setN(n + 1);
  setN(n + 1);
  setN(n + 1);
  console.log(n); // 0: this render's snapshot
}                 // next render: n === 1

Interview trap. Calling the same setter twice with a value derived from the current state applies the change once, because both calls read the same snapshot.

Engineering practice. Use the updater form when the next value depends on the previous one, keep the value you need in a local variable when a handler needs it twice, and read state in the next render rather than after the setter.

updater functions against captured values

Passing a function to a setter queues a transformation that receives the latest queued state, which is the only form that composes when several updates happen before the next render.

React applies the queue in order during the next render, so three updater calls each see the previous result. Mixing the two forms replaces the queue's accumulated value whenever a plain value is passed, because that value came from the old snapshot.

The queue under both forms: 3 updates give 1 with a captured value and 3 with an updater.

setN(n + 1);          // queue: replace with 1
setN((prev) => prev + 1); // queue: 1 -> 2
setN(n + 1);          // queue: replace with 1 again, the updater is discarded
// next render: n === 1

Interview trap. Reaching for a ref or a module variable to escape a stale value replaces a scheduling problem with an invisible one, because the ref does not trigger a render and the UI keeps showing the earlier state.

Engineering practice. Use the updater form for counters, toggles, and anything derived from the previous value, and keep plain values for state that is set from an input rather than computed from itself.

automatic batching of updates

React groups the state updates made during one event into a single re-render, and since React 18 that batching also covers updates made in promises, timeouts, and native handlers.

Several setters in one handler produce one render with all the new values, which avoids rendering a half-updated screen. flushSync forces a synchronous render for the rare case where the DOM must be read between two updates, at the cost of the extra render.

Two setters, one render: 2 calls commit 1 render, in an event handler and in a promise alike.

function onClick() {
  setOpen(true);
  setCount(count + 1);   // one render with both values
}
flushSync(() => setOpen(true)); // forces the commit before the next line
const height = ref.current.offsetHeight;

Interview trap. Expecting a render between two setters leads to code that measures the DOM in the same handler and reads the pre-update layout, so a scroll position or a height is computed from the wrong frame.

Engineering practice. Let batching do its job, use flushSync only where a measurement must sit between two updates, and put layout reads in useLayoutEffect instead of in the handler.

lazy initial state

The argument to useState is the initial value and is kept only on the first render, so passing a call expression runs that work on every render and throws the result away.

Passing a function instead makes React call it once, when the state is created. The same distinction applies to useRef, where an expensive initial value should be created behind a null check rather than on every render.

Eager and lazy initialisation: the argument form runs the parse on all 50 renders, the function form on 1.

const [rows, setRows] = useState(parseCsv(text));   // parses on every render
const [rows2, setRows2] = useState(() => parseCsv(text)); // parses once
const ref = useRef(null);
if (ref.current === null) ref.current = new Worker(url); // created once

Interview trap. Writing useState(expensiveInit()) looks like initialisation but is an unconditional call on every render, so a cost intended to be paid once is paid on every keystroke.

Engineering practice. Pass the function itself for anything more expensive than a literal, measure before assuming an initialiser is cheap, and use the same pattern for refs holding costly objects.

derived values computed rather than stored

Anything that can be computed from existing props and state should be computed during render, because a second copy in state can disagree with its source.

Compute the value in the component body and memoise only if the calculation is measurably expensive. Keep in state only what cannot be derived, which is usually the user's raw input and the identifiers it refers to.

The same filter, two designs: 1 source of truth against 2 values that can disagree.

// stored: two renders per keystroke, and a window where the list is stale
const [visible, setVisible] = useState(items);
useEffect(() => { setVisible(items.filter(match)); }, [items, query]);

// derived: one render, no stale window
const visible2 = items.filter(match);

Interview trap. Storing a filtered list in state and refreshing it from an effect renders once with the stale list before the effect corrects it, which is a visible flash and an extra render on every change.

Engineering practice. Derive during render, memoise on evidence, and treat any effect whose only job is to set state from other state as a design error to be removed. 2 stored values that must agree can disagree; 1 stored value plus 1 derived from it cannot.

keys as identity among siblings

A key tells React which item in a list is which across renders, so it must be stable, unique among its siblings, and derived from the data rather than from the render.

React matches children by key to decide what to move, keep, or destroy, and a matched element keeps its DOM node and its state. Keys are scoped to their sibling list, so the same key in two different lists is not a conflict.

What a fresh key costs: a 1,000-row list unmounts and remounts all 1,000 rows on every render.

items.map((i) => <Row key={crypto.randomUUID()} {...i} />); // remounts every render
items.map((i) => <Row key={i.id} {...i} />);                // reuses nodes and state

Interview trap. Generating a key at render time, with a random value or a counter, gives every item a new identity on every render, so React destroys and recreates the whole list along with any state or focus inside it.

Engineering practice. Use the identifier the data already has, add one at the point the data is created when it has none, and never call a random generator inside the render that produces the key. The cost scales with the list: a key regenerated during render turns 1 re-render of a 1,000-row table into 1,000 unmounts and 1,000 mounts, taking focus and scroll position with them.

the index as a key

An array index identifies a position rather than an item, so it is safe only while the list is append-only and never reordered, filtered, or inserted into.

When an item is inserted at the front, every later item's index shifts, so React matches each element to the wrong data while keeping the DOM node and its internal state. The symptom is state that stays with the position: a checked box or a focused input that follows the slot rather than the row.

Inserting at the front with index keys: 1 insertion shifts all 5 indices and moves every row's state up by 1.

// before: [a, b] as keys 0, 1 with b's input focused
// insert c at the front: [c, a, b] as keys 0, 1, 2
// React reuses node 0 for c and node 1 for a: the focus and any local
// state stay on index 1, which now shows a different row's data

Interview trap. Testing a list only by appending hides the defect entirely, because indices are stable under append and the bug appears the first time a user deletes or reorders a row.

Engineering practice. Use a stable identifier from the data, and if you genuinely have none, generate one when the item is created rather than when it is rendered.

state tied to type and position

React keeps a component's state while the same component type stays in the same position of the rendered tree, and discards it as soon as either changes.

Rendering a different component type at that position unmounts the old subtree with its state, and so does removing the element in a conditional branch. Passing a different key to the same component type is the deliberate way to reset state without changing the structure.

Three ways to lose the state you wanted to keep: 3 edits that each look cosmetic, 3 resets.

function Parent() {
  function Child() { const [v] = useState(0); return <i>{v}</i>; } // new type each render
  return <Child />;
}
{isEditing ? <Input /> : <Input readOnly />}   // same type and position: state kept
<Form key={recordId} />                        // deliberate reset per record

Interview trap. Defining a component inside another component's body creates a new type on every render, so the child unmounts and remounts continuously and every piece of its state is lost.

Engineering practice. Define components at module scope, use a key when you intend a reset such as a form switching to a different record, and check for an accidental type change when state disappears. All 3 of the edits that cause it read as cosmetic in a diff — swapping a wrapper element, moving 1 branch into a conditional, reordering 2 siblings — and each of the 3 discards the subtree's state.

effects as synchronization with an external system

An effect exists to keep something outside React — a subscription, a socket, a browser API, a third-party widget — in step with the current props and state, not to react to a user action.

React runs the effect after the commit, then runs its cleanup and the effect again whenever a dependency changes. Write the effect so that running it twice in a row with the same inputs leaves the external system in the same state.

The same purchase, two placements: the effect sends 2 requests in development, the handler 1.

// effect-driven: runs again if anything else sets submitted
useEffect(() => { if (submitted) buy(cart); }, [submitted, cart]);

// handler-driven: runs once, where the intent is
function onSubmit() { buy(cart); }

Interview trap. Using an effect to respond to a click, by watching a state flag the handler sets, splits one action across two renders and makes the ordering depend on dependency arrays rather than on the code path.

Engineering practice. Put what happened because the user did something in the handler, keep effects for synchronizing with the outside world, and ask what external system the effect is keeping in step before writing one. Development makes the double-run concrete: StrictMode mounts an effect 2 times with 1 cleanup between, so a subscription with no cleanup leaves 2 listeners attached where 1 was intended.

dependency arrays as a declaration

The dependency array is not a filter you choose: it must list every reactive value the effect reads, and the correct way to shorten it is to change the effect so it reads less.

React compares dependencies with Object.is between renders, so a new object or function created during render counts as changed every time. Move a function that only the effect uses inside the effect, hoist a constant out of the component, and keep objects out of the array by depending on the primitive fields you actually read.

An object dependency that changes every render: 1 array entry, a fresh reference each time, so the effect reruns on all 4 renders.

const options = { roomId };                     // new object each render
useEffect(() => connect(options), [options]);   // reconnects every render
useEffect(() => connect({ roomId }), [roomId]); // reconnects when the room changes

Interview trap. Silencing the exhaustive-dependencies lint rule leaves the effect reading values from the render it was created in, so it synchronizes against data that has since changed and the bug appears only after a specific sequence of updates.

Engineering practice. Fix the cause rather than the warning, keep effects small enough that their dependencies are obvious, and use the linter's suggestion as the starting point for restructuring. The shape to watch for is a 1-entry array holding an object literal: 4 renders then mean 4 reruns, where depending on the 2 primitive fields the effect actually reads reruns only when one of them moves.

cleanup and out-of-order responses

An effect's cleanup runs before the next run and on unmount, which is what stops an old subscription, timer, or request from affecting the component after its inputs have changed.

For data fetching, capture a local flag or an AbortController in the effect and check it before setting state, because two requests started in order can return out of order. Without that guard, the slower earlier response overwrites the newer one.

Two searches, the slow one first: replies at 400 ms and 80 ms, and without cleanup the 400 ms answer wins.

useEffect(() => {
  let active = true;
  search(query).then((r) => { if (active) setResults(r); });
  return () => { active = false; };   // the stale response is discarded
}, [query]);

Interview trap. Treating cleanup as an unmount-only hook misses the common case, which is the effect re-running after a dependency changed while the previous operation was still in flight.

Engineering practice. Return a cleanup from every effect that starts something, ignore results from a superseded run, and prefer a framework or library data layer over hand-written fetching in effects.

the effects you do not need

Effects that only adjust state from other state, transform data for rendering, or run logic caused by an interaction can be removed, and removing them deletes a render and a class of bug.

Compute derived data during render, reset state with a key rather than with an effect that watches a prop, share logic between handlers with a plain function, and keep effects for genuinely external systems.

A three-effect cascade collapsed into one render: 3 chained effects cost 4 renders where the derived form costs 1.

// before: three extra renders per selection
useEffect(() => setCity(null), [country]);
useEffect(() => setArea(null), [city]);
useEffect(() => setTotal(price * qty), [price, qty]);

// after: a key resets the child, and the total is derived
<Address key={country} />
const total = price * qty;

Interview trap. Chaining effects so each one's state change triggers the next produces a cascade whose length is the number of renders, and whose ordering is impossible to follow from any single file.

Engineering practice. Audit each effect for what outside React it touches; if the answer is nothing, move the work into render or into the handler that caused it. The cost is measured in commits: 3 effects that each set state from the previous one commit 4 renders for 1 user action, where computing the same values during render commits 1.

StrictMode double invocation in development

In development, StrictMode calls components twice and mounts, unmounts, and remounts each component once, which surfaces impure renders and missing cleanup before they reach production.

The duplication happens only in development builds and only under StrictMode, and the second call's result is discarded. An effect that survives it is one whose cleanup fully reverses what it did, which is also what makes it safe when a dependency changes.

What the second mount checks: 2 setups with 1 cleanup between them, in development only.

useEffect(() => {
  const c = createConnection(roomId);
  c.connect();
  return () => c.disconnect(); // without this, StrictMode leaves two connections
}, [roomId]);

Interview trap. Disabling StrictMode to stop a duplicate request hides the defect rather than fixing it, because the same effect will run twice in production the first time a dependency changes.

Engineering practice. Keep StrictMode on, make every effect idempotent with a working cleanup, and read a doubled request in development as a missing AbortController rather than as a React quirk. Counting is the fastest check: 1 mount logs 2 setups and 1 teardown in a development build and exactly 1 setup in a production build.

useLayoutEffect against useEffect

useEffect runs after the browser has painted, while useLayoutEffect runs synchronously after the DOM is updated and before paint, which is what makes it the right place to measure and adjust layout.

Work in useLayoutEffect blocks painting, so it must be short and limited to measurement and the state change that depends on it. Everything else — subscriptions, requests, logging — belongs in useEffect, where it cannot delay the frame.

Positioning a tooltip above its anchor: 1 visible frame of misplacement at 16.7 ms, or none.

useLayoutEffect(() => {
  const { height } = ref.current.getBoundingClientRect();
  setTop(anchorTop - height);   // applied before the first paint
}, [anchorTop]);

Interview trap. Measuring an element in useEffect and repositioning from the result paints the wrong position first, which the user sees as a flicker on every open.

Engineering practice. Reach for useLayoutEffect only for measure-then-adjust, keep its body free of requests, and check that it does not run on the server where there is no layout to measure. The window is 1 frame — 16.7 ms at 60 fps — so measuring in useEffect shows the user 1 frame at the wrong position and the same work in useLayoutEffect shows 0.

refs for values that do not render

A ref is a mutable box that survives renders and does not trigger one, which makes it right for timer handles, previous values, and DOM nodes, and wrong for anything the output depends on.

Writing ref.current does not schedule a render, so a value read during render from a ref can be stale on screen. Reading or writing a ref during render also breaks purity, which is why refs are touched in handlers and effects instead.

Which box the screen follows: 10 ref writes cause 0 re-renders, 10 state writes cause 10.

const countRef = useRef(0);
const [count, setCount] = useState(0);
function onClick() {
  countRef.current += 1;  // no render: the screen still shows the old number
  setCount((c) => c + 1); // renders with the new number
}

Interview trap. Moving a value into a ref to silence a dependency warning produces a component that renders with old data and only updates when something unrelated causes a render.

Engineering practice. Keep anything the user sees in state, keep bookkeeping the render does not read in refs, and reach for useEffectEvent when a handler needs the latest value without becoming a dependency.

useMemo and useCallback as a measured trade

Memoisation exchanges memory and a comparison on every render for a saved computation, so it pays only when the computation is slow or when the identity of the value is itself load-bearing.

useMemo caches a value between renders while the dependencies are unchanged, and useCallback does the same for a function. React may discard a cached value, for example for an off-screen component, so the calculation must stay correct if it runs again.

Where the memo earns its comparison: sorting 10,000 rows against a 4-property shallow compare.

const sorted = useMemo(() => [...rows].sort(byName), [rows]); // 10,000 rows: worth it
const label = useMemo(() => `${first} ${last}`, [first, last]); // slower than the concat
const onSelect = useCallback((id) => pick(id), [pick]);        // only matters for a memoised child

Interview trap. Wrapping every value in useMemo adds allocations and dependency comparisons to each render and hides the components where memoisation genuinely mattered, which makes the profile harder to read rather than faster.

Engineering practice. Profile first, memoise the expensive calculation and the props passed to a memoised child, and keep state that must survive in state rather than in a memo.

memo and the identity of props

React.memo skips a re-render when every prop is unchanged by Object.is, so it helps only when the parent passes stable values, and objects, arrays, and inline functions created during render never are.

Wrapping a child in memo while passing it a fresh object each render costs a comparison and re-renders anyway. Make the props primitive where possible, memoise the objects and callbacks that must be passed, and measure whether the skipped render was expensive enough to matter.

Why the comparison fails: 4 props, 3 of them equal, and 1 new object reference per render.

const Row = memo(function Row({ item, onPick }) { /* ... */ });
<Row item={{ ...item }} onPick={() => pick(item.id)} /> // both props new each render
<Row item={item} onPick={onPickStable} />               // comparison can succeed

Interview trap. Adding memo to a component whose parent re-renders constantly with inline props is the memoisation that shows up in a profile as pure overhead, because the comparison always fails.

Engineering practice. Fix the props before adding memo, prefer passing children as elements so a subtree is not recreated, and delete memoisation that no measurement supports. The arithmetic is usually against the memo: a 4-property shallow compare runs 4 Object.is calls on every parent render and buys nothing while 1 of those 4 is a fresh object.

context and who re-renders

Context passes a value to every consumer beneath a provider without threading props, and every consumer re-renders when that value changes by identity, however deep it sits.

A provider whose value is an object literal produces a new identity on every parent render, so all consumers re-render even when nothing they read has changed. Memoise the value, or split one context into the parts that change at different rates.

One provider, two shapes: 1 value change re-renders all 40 consumers, or only the 2 that read what moved.

<Ctx.Provider value={{ user, theme }}>          // new object: all consumers re-render
<Ctx.Provider value={useMemo(() => ({ user, theme }), [user, theme])}>
<ThemeCtx.Provider value={theme}><UserCtx.Provider value={user}>  // split by rate

Interview trap. Treating context as a state manager puts frequently changing values in one provider, so a keystroke in a form re-renders every consumer in the tree including ones that read an unrelated field.

Engineering practice. Memoise provider values, split contexts by update frequency, and keep high-frequency state local or in a store with selector-based subscriptions. The blast radius is the consumer count rather than the value size: 1 new object identity re-renders all 40 consumers, while splitting the context leaves 38 of them untouched.

controlled against uncontrolled inputs

A controlled input takes its value from React state on every render, while an uncontrolled one keeps its value in the DOM and is read when needed, and the choice decides where the truth lives.

A controlled input needs both value and onChange, because value without a handler makes the field read-only. An uncontrolled input takes defaultValue and is read through a ref or the form data, which avoids a render per keystroke for large forms.

Three inputs, three behaviours: 20 keystrokes cost 20 renders controlled and 0 uncontrolled.

<input value={name} onChange={(e) => setName(e.target.value)} /> // controlled
<input defaultValue={name} ref={ref} />                          // uncontrolled
<input value={name} />                                           // read-only, with a warning

Interview trap. Switching a field from undefined to a string value mid-life converts an uncontrolled input into a controlled one, which React warns about and which discards what the user had typed.

Engineering practice. Pick one mode per field and initialise it before the first render, use defaultValue for fields that only matter on submit, and read large forms from the form data rather than from per-keystroke state. The difference is 1 render per character: a 20-character entry costs 20 renders controlled and 0 uncontrolled, which matters across 30 fields and not across 3.

hooks and the order they are called in

React identifies a hook by its position in the call order of that component, so hooks must be called at the top level of a component or another hook, in the same order on every render.

Calling a hook inside a condition, a loop, or after an early return shifts every later hook's position, so state lands in the wrong slot. Extracting a set of hooks into a custom hook keeps them in that fixed order while letting two components share the logic and keep separate state.

Where the slots shift: 3 hooks behind 1 condition, and slot 2 reads what slot 3 stored.

if (!user) return null;      // an early return above a hook
const [name, setName] = useState("");  // slot moves between renders

const [name2, setName2] = useState(""); // all hooks first
if (!user) return null;                 // then the branch

Interview trap. Assuming a custom hook shares state between the components that call it confuses shared logic with shared state: each call site gets its own independent state.

Engineering practice. Keep every hook above any early return, put conditional logic inside the hook rather than around it, and use a store or a lifted state when two components really must share one value. The slots are positional: 3 hooks behind 1 early return means the 2nd call reads what the 3rd stored, and the values are plausible enough that the defect survives review.

error boundaries and Suspense boundaries

An error boundary catches errors thrown while rendering its subtree, and a Suspense boundary shows a fallback while its subtree is waiting. They are separate mechanisms for separate conditions.

Error boundaries are class components implementing getDerivedStateFromError or componentDidCatch, and they do not catch errors thrown from event handlers or asynchronous callbacks. Suspense catches suspension rather than failure, so a rejected read reaches the nearest error boundary instead.

Which boundary sees what: 1 thrown error and 1 pending promise reach 2 different fallbacks.

<ErrorBoundary fallback={<Failed />}>       // render errors, rejected reads
  <Suspense fallback={<Skeleton />}>        // waiting only
    <Profile />
  </Suspense>
</ErrorBoundary>
// onClick={() => { throw e; }} reaches neither: use try/catch

Interview trap. Expecting a Suspense fallback to appear when a request fails leaves the user on a spinner forever, because suspension and rejection are handled by different boundaries.

Engineering practice. Place an error boundary above each Suspense boundary you care about, handle handler errors with ordinary try/catch, and give each boundary a fallback that says what failed rather than a bare spinner. The 2 boundaries answer 2 different questions and 1 component can sit inside both: 1 pending read reaches the Suspense fallback and 1 rejection reaches the error fallback above it.

server components against client components

A Server Component runs before the response is sent, has no state, no effects, and no event handlers, and ships no JavaScript for itself; a Client Component is the boundary where interactivity and hooks begin.

The use client directive marks that boundary, and everything imported below it is client code. Props crossing the boundary must be serializable, so functions and class instances cannot be passed, while elements can be passed as children so a server-rendered subtree sits inside a client component without joining the bundle.

Where the boundary goes: 1 directive splits 2 bundles and keeps 0 bytes of server-only code out of the client.

// page.jsx (server): data stays on the server
<Chart data={await load()}>
  <Toggle />        {/* the only client component */}
</Chart>
// Toggle.jsx starts with "use client"; Chart stays server-rendered

Interview trap. Marking a page as client to use one hook pulls its whole import graph into the bundle, which is how a small interactive control ends up shipping a data layer that was supposed to stay on the server.

Engineering practice. Push the client boundary down to the smallest interactive leaf, pass server-rendered content through children, and check the bundle rather than assuming the boundary is where you think it is. That is what the boundary buys: a 300 KB formatting dependency imported by 1 server component ships 0 bytes to the client.