Index

Re-renders, memo and optimisation

Almost every performance question in a React interview is really a question about whether you know what a re-render costs. The wrong model is that a re-render is expensive and memo is the fix; the right one is that a re-render is a function call and a tree walk, usually under a millisecond, and the fix is nearly always to render fewer components rather than to compare more props. Know the four moves that shrink the work - move the state down, pass children through, split the context, window the list - and know that memo is the one you reach for after those, not before.

What causes a re-render, exhaustively

There are exactly four. Its own state changed. Its parent re-rendered. A context it consumes changed value. Or an external store it subscribes to changed. That is the whole list, and being able to recite it is what turns a debugging session from guesswork into elimination.

Two things that are NOT on the list: props changing, and the DOM changing. Props cannot change without the parent re-rendering, which is already reason two - so "the props changed" is a symptom, not a cause. And React re-rendering a component does not mean React touched the DOM: it means it called the function, diffed the output, and wrote only what differed, which is frequently nothing.

Its own state changed
A setState with a value that is not Object.is-equal to the current one. Setting state to the same value bails out.
Its parent re-rendered
The default, and the one people find surprising. React does not compare props automatically. The exception: a child element that was not recreated during the parent's render - one passed in as children or as a prop - is skipped.
A consumed context changed
Every component calling useContext for that context re-renders when the provider's value changes by identity. React.memo does not stop this.
A subscribed external store changed
useSyncExternalStore, and every state library built on it. The selector decides whether this particular component is told.

The ladder, in the order to try it

When something is slow, work down this list and stop as soon as it is fast enough. It is deliberately ordered so that the cheap structural fixes come before the ones that add code you have to maintain.

First, measure - React DevTools Profiler, record the interaction, look at what actually took the time. Second, move state down: if only one component needs the value, it should not live above the expensive tree. Third, pass expensive subtrees through as children so they are not recreated. Fourth, cut the render count structurally - window the list, split the context, memoise the genuinely expensive computation. Fifth, and last, React.memo with stable props, which requires useCallback and useMemo on the way in and is therefore the most code for the least certain gain.

Questions

Answer each one yourself before you open it.

  1. 01What causes a component to re-render?

    Four things: its own state changed, its parent re-rendered, a context it consumes changed, or an external store it subscribes to changed. Props changing is not a separate cause - props only change because the parent re-rendered.

    Reciting the list is the point, because it is how you debug. If a component is rendering too often, exactly one of those four is happening, and DevTools tells you which. The one people forget is the second: React re-renders all descendants by default, without comparing props, and that is the correct default because comparing is not free either.

  2. 02If a parent re-renders, does that mean the child's DOM is rebuilt?

    No. The child's function runs again and produces a new element tree, React diffs it against the previous one, and touches the DOM only where they differ. Same output means zero DOM operations.

    This is the misconception that sends people memoising everything. The cost of an unnecessary re-render is a function call plus a tree walk - measured in microseconds for a normal component. It becomes a real cost when the render itself is heavy (a big computation in the body, hundreds of children) or when it happens sixty times a second. Otherwise, it is noise.

    Follow-up So when does an extra re-render actually hurt?

    When it is multiplied - a thousand rows, or an event firing on every mouse move or keystroke - or when the render body does expensive work. Those are the two shapes worth optimising, and both are visible in the profiler.

  3. 03What is React.memo?

    A wrapper that makes a function component skip re-rendering when its props are shallowly equal to last time. It is the function-component version of PureComponent.

    It compares each prop with Object.is - one level deep, no recursion. It does nothing about state or context: a memoised component still re-renders when its own state changes or when a context it reads changes. And it costs something itself, a comparison per prop per render, so wrapping every component in it makes an app slower rather than faster.

    Follow-up Can you change how it compares?

    Yes, a second argument (prevProps, nextProps) returning a boolean. Note the direction is the opposite of shouldComponentUpdate: return true to SKIP the render.

  4. 04I wrapped a component in memo and it still re-renders every time. Why?

    Almost always because a prop is a new object or a new function on every parent render. Object.is compares references, and an object literal or an arrow function written in JSX is a fresh value each time, so the shallow compare always fails.

    The two shapes to name: style={{ margin: 8 }} or user={{ id }} or items={list.filter(...)} - a new object every render; and onClick={() => save(id)} - a new function every render. The fixes are useMemo for the value and useCallback for the function, or hoisting a truly constant object out of the component entirely. There is a third, quieter cause: passing children, which is a new element object each render unless it came from further up.

  5. 05useMemo versus useCallback?

    useMemo caches the result of calling a function; useCallback caches the function itself. useCallback(fn, deps) is exactly useMemo(() => fn, deps).

    They exist for two different reasons that people conflate. useMemo is for skipping an expensive computation, and secondarily for keeping an object's identity stable so a memoised child or another hook's dependency array does not see a change. useCallback is only ever about identity - a function's body does not get cheaper by being cached - so a useCallback whose result is not passed to a memoised child or used in a dependency array is pure overhead.

    Follow-up Is useMemo a guarantee?

    No. React documents it as a performance hint and reserves the right to throw the cache away - it does, for offscreen content. So never put anything load-bearing in a useMemo: no subscriptions, no ids you depend on being stable for correctness. If you need a guaranteed stable value, use a ref.

  6. 06When is memoising cargo cult?

    When you cannot say which re-render it prevents and what that re-render was costing. Wrapping a leaf that renders a span, or memoising a computation that is one string concatenation, adds a comparison and a cache to save nothing.

    It is not free: every memo is a shallow compare per render, every useMemo and useCallback is an array comparison plus a retained closure, and each one adds a dependency array that will go stale in review. The honest heuristic is to add them where a measurement says to, plus the one prophylactic case - a component you are shipping as a library, where you do not control how often the caller re-renders.

    Follow-up Does the React Compiler change this?

    That is its whole pitch: it inserts the memoisation automatically at build time, so the manual hooks become unnecessary in components it can prove are safe. It rewards the same discipline - pure components, no mutation - because it bails out where it cannot prove purity.

  7. 07What is lifting state down?

    Moving a piece of state from a component that renders a big tree into the smallest component that actually uses it, so a change to it re-renders that component instead of everything.

    This is the first fix to reach for and it needs no new API. A page with a search input at the top and a heavy table below will re-render the table on every keystroke if the input's value lives on the page. Extract the input, along with its state, into its own component and the table sits still - no memo, no useCallback, less code than before. The same move done to a whole subtree is what people call "extracting an isolated component".

  8. 08How does passing children through avoid re-renders?

    A component that owns state and renders {children} does not re-render those children when its own state changes - the children elements were created by the parent above, and React skips a subtree whose element is referentially identical to last time.

    It is the bailout most people have never heard of and it is entirely free. Take a component that holds a scroll or hover state and wraps something expensive: written the obvious way it re-renders that expensive tree on every change. Restructure it so the expensive part is passed in as children from a component that does not re-render, and React's reconciler sees the same element object and stops there. No memo, no dependency array, nothing to keep in sync.

    Follow-up Does it work through any prop or only children?

    Any prop holding an element gets the same treatment - children is not special, it just has syntax. The requirement is that the element object is created somewhere that is not re-rendering.

  9. 09What is context's re-render problem?

    Every component consuming a context re-renders when the provider's value changes by identity, regardless of whether it reads the part that changed - and React.memo does not stop it.

    Two failure modes stack. First, the value is usually an object literal in the provider's JSX, so it is a new identity on every render of the provider even when nothing inside it changed. Second, a context that holds several unrelated things means a change to any of them re-renders every consumer of all of them. In an app-wide context holding user, theme and a modal flag, opening a modal re-renders everything that reads the theme.

  10. 10How do you fix it?

    Three moves. Memoise the provider value so its identity is stable. Split one context into several by change frequency. And separate the state from the setters into two contexts, so components that only dispatch never re-render at all.

    The third is the one that impresses: dispatch functions from useReducer are stable forever, so a context holding only them never changes value, and the many components that only write are permanently insulated from the ones that read. Beyond that the escape hatch is a store with selectors - useSyncExternalStore or a library - where a component subscribes to a slice and is told only when that slice changes, which is the thing context structurally cannot do.

  11. 11Does React.memo protect a component that reads context?

    No. memo compares props, and context is not a prop. A memoised component whose props never change still re-renders whenever a context it consumes changes value.

    The workaround is structural: split the component so that the small piece reading the context is separate from the expensive piece that does not, and pass what it needs down as props. That way the context change re-renders a thin wrapper and the memoised body underneath is skipped.

  12. 12You have a list of ten thousand rows and scrolling is janky. What do you do?

    Virtualise it - render only the rows in the viewport plus a small overscan, and use a spacer element sized to the full list so the scrollbar stays honest.

    The cost is not React's diff, it is the browser: ten thousand DOM nodes is memory, style recalculation and layout on every change, and no amount of memoising reduces the node count. Windowing turns an O(n) render into O(visible), which is a constant twenty or thirty. Say a library name - react-window, react-virtual, TanStack Virtual - and note the trade: rows lose their DOM state when they scroll out, in-page find stops working, and variable row heights need measurement.

    Follow-up What if the rows have different heights?

    Measure them as they render and cache the offsets, which is what the libraries do. Estimating a fixed height and correcting is the usual approach; getting it wrong shows up as the scrollbar jumping.

  13. 13What is code splitting and how do you do it in React?

    Breaking the bundle into chunks that load on demand, so the first page does not pay for the whole app. In React it is React.lazy around a dynamic import, rendered inside a Suspense boundary that supplies a fallback.

    The natural split points are routes first - nobody needs the settings page on the landing page - then heavy components behind an interaction: a chart library, a rich text editor, a date picker, a modal's contents. Two things to volunteer: wrap the lazy boundary in an error boundary too, because a chunk that fails to load throws; and preload on hover or on intent, so the click does not pay the network round trip.

  14. 14How would you reduce bundle size?

    Measure first with a bundle analyser, then in order: split by route, replace or drop the biggest dependencies, import only what you use so tree shaking works, and move anything that does not need to be interactive off the client entirely.

    The wins are almost always one or two dependencies rather than your own code - a date library imported whole, an icon set imported by namespace, a chart library on the landing page, three copies of the same utility from transitive dependencies. Import named exports from ES-module builds so the bundler can drop what is unused; a default import of a CommonJS package usually cannot be tree-shaken at all.

  15. 15Why does changing a key remount a component, and when is that what you want?

    Because reconciliation matches by key: a different key means React treats it as a different element, unmounts the old one - running cleanups and destroying state - and mounts a fresh one.

    As a bug, it is a key computed at render time, which remounts everything on every render and is one of the most expensive mistakes you can make in a list. As a feature, it is the cleanest way to reset a subtree: key={userId} on a profile form clears every field when the user changes, with no effect syncing props into state.

  16. 16Your list re-renders on every keystroke. Walk me through what you do.

    First establish what is actually slow with the Profiler - the render count, or the work inside one render. Then, in order: move the input's state into its own component so the list is not a sibling of the thing that changes; memoise the filtering with useMemo keyed on the query and the data; memo the row component with stable props; mark the filtering as non-urgent with useDeferredValue or startTransition so typing stays responsive; and if the list is long, virtualise it.

    This is the headline question of the topic and the grading is on the order. Someone who opens with "wrap the row in memo" has skipped the two fixes that are free and structural. Someone who opens with "measure, then move the state down" has the senior answer. Debouncing is worth mentioning last and with a caveat: it delays every keystroke by a fixed amount whether or not the work is slow, whereas a transition starts immediately and gets interrupted.

  17. 17How do you use the React DevTools Profiler?

    Record while you perform the slow interaction, then read the commits: each bar is one commit, its height is how long it took, and the flamegraph shows which components rendered and for how long. Grey means it did not re-render.

    Two settings make it useful. Turn on "Record why each component rendered" - it tells you whether it was hooks, props, parent or context, which is your four causes directly. And use the ranked chart to find the single most expensive component rather than reading the tree. Also worth naming: the Profiler measures development builds, which are slower than production, so use it to find the relative hotspot, not to quote a number.

    Follow-up Anything outside React DevTools?

    The browser's own performance panel, for anything that is not React - layout thrash, long tasks, style recalculation. If the flamegraph shows React finishing quickly and the frame is still long, the cost is in the browser, and a component-level fix will not help.

  18. 18What does useTransition give you that a debounce does not?

    The update starts immediately at low priority and can be interrupted and thrown away, so the urgent update - the keystroke - always lands in the next frame. A debounce is a fixed delay applied whether or not the work is slow, and once it fires the work is uninterruptible.

    useTransition also hands you isPending, so you can dim the stale results while keeping them on screen instead of flashing a spinner. The shape is: setQuery(value) outside the transition so the input is instant, startTransition(() => setResults(...)) for the expensive part. If the user types again mid-render, React abandons the in-progress render rather than finishing work nobody will see.

    Follow-up So debouncing is obsolete?

    No - debounce is still right when the cost is not rendering but a network request or something you pay for per call. Transitions are for expensive rendering; debounce is for expensive side effects.

  19. 19useDeferredValue or useTransition - which one?

    useTransition when you own the setState and can wrap it. useDeferredValue when you only have a value - one arriving as a prop, or one you want to keep instant in an input while the expensive consumer lags behind.

    They are the same scheduling mechanism from two ends. The deferred pattern is: keep query in normal state so the input never lags, pass useDeferredValue(query) to the heavy child, and compare the two to know you are showing stale output. That comparison is the equivalent of isPending.

  20. 20Is putting an arrow function in onClick a performance problem?

    On a plain DOM element, no. React attaches one delegated listener and swapping the function reference is trivial. It matters only when that function is a prop to a memoised child, where the new identity defeats the comparison.

    Worth saying because the "never use inline arrows" rule gets repeated as universal, and following it means wrapping every handler in useCallback, which is more code, more dependency arrays and measurably more work than the thing it avoids. The rule with the condition attached - inline functions are fine unless the child is memoised - is the correct one.

  21. 21What is the lazy initialiser for useState and why does it matter?

    Passing a function instead of a value: useState(() => expensive()). React calls it once, on mount. Passing useState(expensive()) calls it on every render and throws the result away every time except the first.

    The argument is evaluated before useState is called - that is plain JavaScript, not a React rule - so React never sees the expensive call, it just receives a value it ignores. Same shape as useRef(new Thing()), which constructs an object on every render and discards it. The tell is any argument that reads localStorage, parses JSON or loops.

  22. 22Should you memoise every component in a shared component library?

    It is the one place where memoising without a measurement is defensible, because you do not control how often the consumer re-renders and cannot profile their app.

    Even there, the useful version is narrower: memo the components that render a lot of children or do real work, and make sure your own callbacks and objects are stable so a consumer's memo is not defeated by you. Blanket-memoising leaf components that render a single element costs the comparison for no gain in anybody's app.

  23. 23What is the difference between too many re-renders and one slow render?

    Too many re-renders is a scheduling and structure problem - fix it by moving state, passing children, splitting context, memoising props. One slow render is a work problem - fix it with useMemo, less work in the body, or fewer nodes.

    They have completely different fixes and the profiler distinguishes them at a glance: many short commits versus one tall one. Applying the wrong family of fix is the most common wasted afternoon in React performance work - memoising props does nothing for a component that renders three thousand rows once.

  24. 24Does useMemo help if the dependency array is unstable?

    No - it recomputes every render and you have paid for the cache without ever hitting it. The most common cause is an object or array in the dependency list that is recreated in the component body.

    The chain has to be stable all the way up: memoising a value that depends on an object that is recreated each render just moves the problem. Depend on primitives where you can - options.limit rather than options - or hoist the constant object out of the component. The eslint exhaustive-deps rule catches the missing entries, not the unstable ones, so this is one you have to see yourself.

  25. 25How do you avoid re-rendering the whole app when one deeply nested value changes?

    Do not put it in a context that everything reads. Either keep it in the subtree that uses it, or put it in a store where components subscribe to a slice and only the subscribers to that slice re-render.

    This is where context runs out. Context broadcasts: every consumer is told about every change. A store with selectors - useSyncExternalStore under the hood - inverts that, letting a component say what it cares about. That is the actual technical argument for a state library over context, and it is a better answer than "context is only for low-frequency values", though that shorthand is also true.

  26. 26What is useSyncExternalStore for?

    Subscribing a component to state that lives outside React - a store, a browser API, a websocket - in a way that is safe under concurrent rendering.

    The problem it solves is tearing: with interruptible rendering, one part of a tree can read the external value before it changes and another part after, so a single frame shows two different values. useSyncExternalStore gives React the subscribe and getSnapshot functions it needs to guarantee one consistent read per render. Every serious state library uses it, which is why you can name it even if you never call it directly.

  27. 27What in a React app is usually slower than React?

    The network and the images. A hundred kilobytes of unnecessary JavaScript on a slow connection costs more than every unnecessary re-render in the app put together, and one unoptimised hero image can cost more than that again.

    Worth saying because it reframes the whole topic and interviewers notice. The ordered list for a real page is: ship less JavaScript, size and format the images properly, avoid request waterfalls, then worry about rendering. Also name the waterfall specifically - a component that fetches, renders a child that fetches, which renders a child that fetches - because it is a React-shaped problem with a React-shaped fix: hoist the fetches, or fetch at the route level.

  28. 28What metric would you actually optimise for?

    The user-facing ones: how long until the page is usable, and how long from a click or keystroke to visible feedback. Interaction to Next Paint is the standardised version of the second.

    Render counts are a diagnostic, not a goal - an app can halve its re-renders and feel identical. Tying the answer to a measurable outcome, and saying you would measure before and after, is the difference between someone who optimises and someone who tinkers.

  29. 29Have you ever made an app slower by optimising it?

    It is the standard outcome of blanket memoisation: every wrapper adds a comparison and a retained closure, and if the renders being skipped were cheap you have added work on every render to avoid work that cost nothing.

    Good answers here are specific: useCallback on handlers passed to plain DOM elements, useMemo on values that are already primitives, memo on leaves. There is also a correctness cost - every dependency array is a place for a stale closure to hide, and a memoised component that quietly keeps rendering old data is a worse bug than a slow one.

  30. 30What is the point of the overscan in a virtualised list?

    Rendering a few rows above and below the viewport so that a fast scroll does not reveal blank space before the new rows mount.

    It is the tuning knob of every windowing implementation: too small and you get flashes of white on a fling scroll, too large and you have given back the saving. Being able to name it, and the spacer element that gives the scrollbar the right size, shows you have implemented one rather than only imported one.

  31. 31Does React.memo compare deeply?

    No - one level, with Object.is on each prop. Two objects with identical contents are not equal, which is why a fresh object literal defeats it.

    You can pass a custom comparator as the second argument, and a deep comparison there is usually a mistake: you have replaced a cheap render with an expensive walk of the data on every render. If a prop is an object that changes identity but not content, the fix is upstream - memoise the object where it is created - not a deeper compare downstream.

  32. 32When would you deliberately NOT optimise a re-render?

    When the component is cheap, which is most of them. If the profiler shows the commit at a fraction of a millisecond, the correct action is to leave it alone and keep the code simpler.

    Volunteering this is a signal in itself. The cost of an optimisation is not runtime, it is the dependency array somebody has to maintain and the stale closure it will eventually hide. "It renders more than it needs to and that is fine" is a legitimate engineering position, and the number of candidates who will say it out loud is small.

Predict the output

The output is hidden. Predict it before you open it.

React.memo, and the prop that defeats it

const Row = React.memo(function Row({ label, meta }) {
  console.log("row render", label);
  return null;
});

function App() {
  const [n, setN] = useState(0);
  return (
    <div>
      <Row label="plain" meta="x" />
      <Row label="object" meta={{ x: 1 }} />
      <button onClick={() => setN(n + 1)}>+</button>
    </div>
  );
}

// mount, then one click
Show the output
row render plain
row render object
row render object

Both rows mount. On the click, App re-renders and memo compares props with Object.is: for the first row "x" equals "x", so it is skipped. For the second, the object literal is a brand new reference every render, so the compare fails and it renders. Same story for an inline arrow function prop.

useCallback restoring the memo

const Child = React.memo(function Child({ onPick }) {
  console.log("child render");
  return <button onClick={onPick}>pick</button>;
});

function Parent() {
  const [n, setN] = useState(0);
  const onPick = useCallback(() => {}, []);
  console.log("parent render");

  return (
    <div>
      <Child onPick={onPick} />
      <button onClick={() => setN(n + 1)}>+</button>
    </div>
  );
}

// mount, then one click
Show the output
parent render
child render
parent render

With an empty dependency array useCallback hands back the same function every render, so memo's compare passes and Child is skipped. Remove the useCallback and the third line would be child render as well. Note what it is worth: skipping one function call, which is only a win because it might have been a thousand.

useCallback IS useMemo

function Probe({ n }) {
  const raw = () => "hi";
  const a = useCallback(raw, []);
  const b = useMemo(() => raw, []);

  console.log(n, a === raw, a === b);
  return null;
}

// render <Probe n={1} />, then <Probe n={2} />
Show the output
1 true true
2 false true

On the first render both return the function just created, so all three are the same value. On the second, raw is a new function but the cache still holds the original - so a === raw is false while a === b stays true. That is the definition: useCallback(fn, deps) is useMemo(() => fn, deps), and neither makes the function cheaper, only stable.

Passing children through

function Slow() {
  console.log("slow render");
  return null;
}

function Shell({ children }) {
  const [n, setN] = useState(0);
  console.log("shell render");
  return (
    <div>
      <button onClick={() => setN(n + 1)}>+</button>
      {children}
    </div>
  );
}

function Page() {
  return <Shell><Slow /></Shell>;
}

// mount Page, then one click on +
Show the output
shell render
slow render
shell render

Shell owns state and re-renders, but Slow does not - the <Slow /> element was created by Page, which did not re-render, so React sees the identical element object and bails out of that subtree. No memo, no useCallback, no dependency array. This is the cheapest optimisation in React and the least known.

Context defeats memo

const Ctx = React.createContext(null);

const Deep = React.memo(function Deep() {
  const value = useContext(Ctx);
  console.log("deep render", value.label);
  return null;
});

function App() {
  const [n, setN] = useState(0);
  return (
    <Ctx.Provider value={{ label: "a" }}>
      <button onClick={() => setN(n + 1)}>+</button>
      <Deep />
    </Ctx.Provider>
  );
}

// mount, then one click
Show the output
deep render a
deep render a

Deep takes no props, so memo has nothing to compare and should skip - but it renders anyway, because the provider's value is a new object literal on every App render and a context change re-renders every consumer regardless of memo. Fix the identity with useMemo on the value; fix the broadcast by splitting the context.

A changed key is an unmount

function Row({ id }) {
  useEffect(() => {
    console.log("mount", id);
    return () => console.log("unmount", id);
  }, []);
  return null;
}

function App() {
  const [id, setId] = useState(1);
  return (
    <div>
      <Row key={id} id={id} />
      <button onClick={() => setId(2)}>next</button>
    </div>
  );
}

// mount, then one click
Show the output
mount 1
unmount 1
mount 2

Same component, same position, different key - so React destroys the instance and builds a new one, running the cleanup and losing all internal state. That is the bug when the key is unstable and the feature when you want a subtree reset. Note the order: React flushes all passive cleanups before any passive setups in a commit.

A useMemo that never hits

function App() {
  const [n, setN] = useState(0);
  const options = { limit: 10 };

  const value = useMemo(() => {
    console.log("compute");
    return options.limit * 2;
  }, [options]);

  return <button onClick={() => setN(n + 1)}>{value}</button>;
}

// mount, then one click
Show the output
compute
compute

options is rebuilt on every render, so its identity changes, so the dependency compare fails and the memo recomputes every time - you have paid for the cache and never hit it. Depend on the primitive ([options.limit]) or hoist the constant object outside the component.

The eager useState argument

function seed() {
  console.log("seed");
  return 0;
}

function Counter() {
  const [n, setN] = useState(seed());
  return <button onClick={() => setN(n + 1)}>{n}</button>;
}

// mount, then one click
Show the output
seed
seed

seed() is evaluated before useState is even called - plain JavaScript argument evaluation - so it runs on every render and React discards the result after the first. Pass the function itself, useState(seed), and React calls it once on mount. Same trap with useRef(new Thing()).

Lifting state down

function Slow() {
  console.log("slow render");
  return null;
}

function Field() {
  const [text, setText] = useState("");
  console.log("field render");
  return <input value={text} onChange={(e) => setText(e.target.value)} />;
}

function Page() {
  console.log("page render");
  return <div><Field /><Slow /></div>;
}

// mount, then type one character
Show the output
page render
field render
slow render
field render

The keystroke re-renders only Field, because that is where the state lives. Had text lived on Page, every keystroke would print all three lines. This is the first fix to try and it removes code rather than adding it - no memo, no useCallback.

memo's comparator returns true to SKIP

const Row = React.memo(
  function Row({ user }) {
    console.log("row render", user.id);
    return null;
  },
  (prev, next) => prev.user.id === next.user.id
);

function App() {
  const [n, setN] = useState(0);
  return (
    <div>
      <Row user={{ id: 1, seen: n }} />
      <button onClick={() => setN(n + 1)}>+</button>
    </div>
  );
}

// mount, then one click
Show the output
row render 1

The click gives Row a brand new user object, but the comparator says the ids match, so React skips the render - and the row never sees the updated seen value, which is the hazard. The direction is the opposite of shouldComponentUpdate: memo's comparator returns true for "equal, skip it", shouldComponentUpdate returns true for "go ahead and render".

Write it from memory

Complete, runnable code. Practice typing it from memory before your interview.

A virtualised list, from scratch

Asked as "how would you render a hundred thousand rows?". You do not need a library to answer it - a fixed row height, a spacer, and a slice.

import { useState } from "react";

const ROW_HEIGHT = 32;
const OVERSCAN = 5;

export function VirtualList({ items, height = 400 }) {
  const [scrollTop, setScrollTop] = useState(0);

  const totalHeight = items.length * ROW_HEIGHT;
  const firstVisible = Math.floor(scrollTop / ROW_HEIGHT);
  const visibleCount = Math.ceil(height / ROW_HEIGHT);

  const start = Math.max(0, firstVisible - OVERSCAN);
  const end = Math.min(items.length, firstVisible + visibleCount + OVERSCAN);
  const slice = items.slice(start, end);

  return (
    <div
      style={{ height, overflowY: "auto" }}
      onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}
    >
      {/* The spacer: gives the scrollbar the right size. */}
      <div style={{ height: totalHeight, position: "relative" }}>
        {slice.map((item, i) => (
          <div
            key={item.id}
            style={{
              position: "absolute",
              top: (start + i) * ROW_HEIGHT,
              left: 0,
              right: 0,
              height: ROW_HEIGHT,
            }}
          >
            {item.label}
          </div>
        ))}
      </div>
    </div>
  );
}

// ---- using it ----

const ROWS = Array.from({ length: 100000 }, (_, i) => ({
  id: i,
  label: "row " + i,
}));

export function Demo() {
  return <VirtualList items={ROWS} />;
}
  • Two invariants make it work: the spacer is the full height so the scrollbar is honest, and each row is absolutely positioned at its true offset so the slice appears in the right place.
  • The overscan is why a fast scroll does not flash white. Five rows either side is a normal starting value.
  • The keys come from the data, not from the slice index - key={i} inside the slice would remount every row on every scroll, which is the exact opposite of the goal.
  • Say the limits out loud: fixed row height only (variable heights need measurement and a cached offset table), rows lose DOM state when they scroll out, and browser find-in-page only sees what is rendered.

A search that does not lag while typing

The direct answer to "your list re-renders on every keystroke". Every technique in the topic, in the order you would apply them.

import { useState, useDeferredValue, useMemo, memo } from "react";

const Results = memo(function Results({ items }) {
  return (
    <ul>
      {items.map((item) => (
        <li key={item.id}>{item.label}</li>
      ))}
    </ul>
  );
});

export function Search({ all }) {
  const [query, setQuery] = useState("");

  // The input stays on `query` so typing is never delayed.
  // The expensive consumer reads the deferred copy.
  const deferred = useDeferredValue(query);
  const isStale = query !== deferred;

  const matches = useMemo(() => {
    const needle = deferred.trim().toLowerCase();
    if (!needle) return all;
    return all.filter((item) => item.label.toLowerCase().includes(needle));
  }, [all, deferred]);

  return (
    <div>
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="search"
        aria-label="search"
      />
      <p aria-live="polite">{matches.length} matches</p>
      <div style={{ opacity: isStale ? 0.6 : 1 }}>
        <Results items={matches} />
      </div>
    </div>
  );
}
  • useDeferredValue is what keeps the input responsive: React renders the new query urgently and the filtered list at low priority, abandoning that render if another keystroke arrives.
  • isStale replaces a spinner. The old results stay on screen, dimmed, instead of the list flashing empty on every character.
  • The useMemo is doing real work here - the filter over the whole dataset - which is the case it is for. Its dependencies are the array and a string, both stable identities.
  • memo on Results pays off because the props are stable: matches only changes identity when the memo recomputes.
  • If the search hit the network instead of a local array, this would be the wrong tool - debounce the request, because the cost there is per call, not per render.

Route-level code splitting

Asked alongside bundle size. Three imports and a boundary - worth being able to type without thinking.

import { lazy, Suspense, Component } from "react";

const Settings = lazy(() => import("./Settings.jsx"));
const Reports = lazy(() => import("./Reports.jsx"));

class ChunkBoundary extends Component {
  state = { error: null };

  static getDerivedStateFromError(error) {
    return { error };
  }

  render() {
    if (this.state.error) {
      return (
        <div role="alert">
          <p>That section failed to load.</p>
          <button onClick={() => window.location.reload()}>Reload</button>
        </div>
      );
    }
    return this.props.children;
  }
}

export function App({ route }) {
  return (
    <ChunkBoundary>
      <Suspense fallback={<p>Loading…</p>}>
        {route === "settings" ? <Settings /> : null}
        {route === "reports" ? <Reports /> : null}
      </Suspense>
    </ChunkBoundary>
  );
}

// Preload on intent, so the click does not pay the round trip.
export function NavLink({ to, onGo, children }) {
  return (
    <a
      href={"/" + to}
      onMouseEnter={() => {
        if (to === "settings") import("./Settings.jsx");
        if (to === "reports") import("./Reports.jsx");
      }}
      onClick={(e) => {
        e.preventDefault();
        onGo(to);
      }}
    >
      {children}
    </a>
  );
}
  • lazy takes a function returning a dynamic import, and the module must have a default export. It has to be called at module scope, not inside a component, or you create a new lazy component on every render and remount the chunk.
  • The error boundary is the part people leave out. A chunk request that fails - a deploy mid-session, a flaky connection - throws, and without a boundary that is a blank page.
  • Preloading on hover or focus is most of the perceived win. The import is already cached by the time the click lands, so the fallback never appears.
  • Split by route first, then by heavy interaction-gated component: an editor, a chart library, the contents of a modal.
← Components, state, props and lifecycleHTML: semantics, forms and meta →