React – Interview Questions

August 20, 202622 min readUpdated 8/18/2026

Twenty-four questions pitched at senior level — the ones that separate someone who has used React from someone who understands why it behaves the way it does. Most of them are not really about API surface; they are about rendering, identity and when not to reach for a feature.

Every answer here is grounded in the same application the rest of this track uses: a pizza ordering site with a cart, forms, routing, a lazily-loaded admin dashboard and Redux. Where an answer has a longer version, it is linked.

Rendering and reconciliation

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

Exactly three things:

  • Its own state changed — a useState setter or a useReducer dispatch that produced a new value.
  • Its parent re-rendered. This one catches people out: a child re-renders even if none of its props changed, unless it is wrapped in memo.
  • A context it consumes published a new value.

What does not cause a re-render: mutating an object, changing a ref's .current, or changing a module-level variable. React compares with Object.is, so setItems(items) after items.push(x) is a no-op — same reference, nothing happened as far as React is concerned.

The senior follow-up is usually "so how do you stop a child re-rendering?" — and the honest first answer is that you usually should not bother, because re-rendering is cheap and the real cost is the DOM commit, which React already skips when the output is unchanged.

2. Explain reconciliation, and why key matters.

When state changes, React produces a new element tree and diffs it against the old one to work out the minimum set of DOM operations. To keep it linear rather than O(n³) it makes two assumptions: different element types produce different trees, and the developer will tell it which children are stable across renders — which is what key is.

Within a list, position alone is ambiguous. If the first item disappears, did everything shift up by one, or did every item's content change? The key answers that, so React can move a DOM node instead of rebuilding it, and keep whatever state lives inside it.

Using the array index defeats the whole mechanism, because the index describes the position rather than the item:

{items.map((item, index) => (
  <CartLine key={index} item={item} />     // works until the list changes
))}

Delete the first of three cart lines and React sees key 0 still present with different content, concludes the item was edited rather than removed, and reuses that DOM node — so the quantity dropdown of the deleted row is now attached to the second row. For read-only text you may never notice; the moment a row has state or an uncontrolled input, you do.

Longer version: Rendering Lists and Keys.

3. Why is declaring a component inside another component a bug?

Because component identity is the function reference, and an inner declaration produces a new function on every render:

function MenuPage() {
  const [filter, setFilter] = useState('ALL');

  // A brand-new function on every render of MenuPage.
  function ProductRow({ product }) {
    return <li>{product.name}</li>;
  }

  return <ul>{products.map((p) => <ProductRow key={p.id} product={p} />)}</ul>;
}

React compares element types by reference. A different type means "this is a different component", so it unmounts the whole subtree and mounts a fresh one — every render. State inside those rows is lost, inputs lose focus mid-typing, and every effect re-runs. It presents as a state bug and it is a definition-location bug.

The same mechanism used deliberately is a useful tool: change a component's key and React discards it and mounts a fresh one, which is the idiomatic way to reset a form when the thing it is editing changes.

State

4. Why does this only increment once?

function handleClick() {
  setCount(count + 1);
  setCount(count + 1);
  setCount(count + 1);
}

Because count is a const belonging to this render. Setting state does not change the variable you are holding — it schedules a render with a new one. All three calls read the same frozen 0 and all three say "make it 1", and React performs one re-render for the whole handler.

The fix is the updater form, which receives the latest pending state:

function handleClick() {
  setCount((c) => c + 1);
  setCount((c) => c + 1);
  setCount((c) => c + 1);       // now it really is 3
}

The rule: if the new value depends on the old one, use the updater form.

A strong answer names why this design is good rather than treating it as a gotcha — everything a render sees is frozen for the duration of that render, so a handler can never observe half-updated state. A strong answer also knows that React 18 extended automatic batching to promises, setTimeout and native event handlers, where React 17 batched only inside React event handlers.

5. Why does React insist on immutable state updates?

Because reference comparison is what the rest of React is built on. memo, useMemo, useCallback and every dependency array decide whether something changed by comparing references. Mutating one object in place quietly switches all of that off at once, and it also makes time-travel debugging and cheap undo impossible.

The three list operations, all non-mutating — this is the real cart reducer:

// add
items: [...state.items, action.payload]

// remove
items: state.items.filter((item) => item.lineId !== action.payload.lineId)

// update one
items: state.items.map((item) =>
  item.lineId === target ? { ...item, quantity: next } : item,
)

Note that map returns every non-matching item as is, preserving its identity — which is exactly what lets a memo'd row skip re-rendering. The mutating methods to watch for are push, splice, and especially sort and reverse, which read like they return a new array and do not.

Redux Toolkit appears to break this rule and does not — it runs reducers through Immer, which records writes against a draft and produces the copy for you.

6. What is a stale closure, and where do you meet one?

An event handler or effect callback closes over the props and state of the render that created it. If it outlives that render, it is holding old values.

The classic is a timer. Here is the real toast provider — it adds a toast and removes it three seconds later:

const showToast = useCallback((message: string, variant: ToastVariant = 'success') => {
  const id = crypto.randomUUID();
  setToasts((current) => [...current, { id, message, variant }]);

  window.setTimeout(() => {
    setToasts((current) => current.filter((toast) => toast.id !== id));
  }, 3000);
}, []);

Written as setToasts([...toasts, newToast]) it would be broken twice over: firing two toasts quickly would lose the first, and the timeout would restore a three-second-old snapshot of the list. The updater form reads nothing from the closure, which is also why the empty useCallback dependency array is correct rather than a shortcut.

The other common cause is an effect with an under-specified dependency array — the effect keeps the first render's values forever. Which is question 8.

Hooks

7. Why can't hooks be called conditionally?

Because React identifies hook state by call order, not by name. It has nothing else to go on — useState is called with a value, not a key. Internally each component has an ordered list of hook slots, and the nth useState call maps to the nth slot.

Put one behind an if and the number of calls changes between renders, so slot 2 now holds what slot 3 held, and state silently belongs to the wrong variable. React detects the count mismatch and throws "Rendered fewer hooks than expected".

In practice the cause is almost always a hook after an early return, which is easy to miss when a guard clause is added later.

8. What goes wrong when you lie to a dependency array?

Two opposite failures, and a senior answer names both.

Under-listing gives you a stale closure — the effect or memo keeps values from the render that created it:

// The memo keeps the FIRST filter's results forever.
const visible = useMemo(() => products.filter((p) => p.type === activeFilter), [products]);

Listing something recreated every render means it never matches, so the effect runs every render — and if the effect sets state, that is an infinite loop:

// `options` is a new object every render, so the effect runs every render.
const options = { page, size: 20 };
useEffect(() => { void load(options); }, [options]);

The important part of the answer is what you do about it. When react-hooks/exhaustive-deps wants a dependency you do not want to react to, the fix is to restructure — move the value into a ref, wrap the function in useCallback, compute it inside the effect, or depend on the primitive fields rather than the object. Deleting it from the array and adding an eslint-disable is how the bug ships.

9. Name three effects that should be deleted.

This is really "do you know that most effects should not exist".

Deriving data. An effect that watches state to set other state costs an extra render and creates a second source of truth that can go stale:

// Wrong.
const [pizzas, setPizzas] = useState<Product[]>([]);
useEffect(() => {
  setPizzas(products.filter((p) => p.type === 'PIZZA'));
}, [products]);

// Right — just calculate it.
const pizzas = useMemo(() => products.filter((p) => p.type === 'PIZZA'), [products]);

Reacting to an event. The click already happened in a handler; watching state to discover it is indirect and fragile. Show the toast, send the request and navigate in the handler.

Resetting state when a prop changes. Usually better expressed as a key on the component, which resets everything in one line.

The rule of thumb: an effect synchronises React with something outside React. If both sides of the problem are inside React, you want a calculation during render or a line in an event handler.

10. How do you fetch data in an effect without race conditions?

The naive version has a real bug: two requests in flight can resolve out of order, and the slower, staler one wins. It also sets state on unmounted components.

The answer is a cleanup function. Here is the real menu fetch:

useEffect(() => {
  const controller = new AbortController();

  async function load() {
    setLoading(true);
    setError(null);
    try {
      const [productData, toppingData, crustData] = await Promise.all([
        api.get<Product[]>('/api/products', { signal: controller.signal }),
        api.get<Topping[]>('/api/toppings', { signal: controller.signal }),
        api.get<Crust[]>('/api/crusts', { signal: controller.signal }),
      ]);
      setProducts(productData);
      setToppings(toppingData);
      setCrusts(crustData);
    } catch (err) {
      // An abort is not a failure — it means we navigated away or StrictMode re-ran the effect.
      if (controller.signal.aborted) return;
      setError('Could not load the menu.');
    } finally {
      if (!controller.signal.aborted) setLoading(false);
    }
  }

  void load();
  return () => controller.abort();
}, [reloadToken]);

Four details worth naming: the effect callback cannot itself be async (its return value must be the cleanup function, not a promise); AbortController plus cleanup is what makes it safe to re-run or unmount mid-flight; an abort is caught and ignored rather than shown as a failure; and Promise.all rather than three sequential awaits.

The strongest close is admitting you would not write this by hand in production — caching, deduplication, retries and revalidation are what TanStack Query or RTK Query already do.

11. When do you use a ref instead of state?

When the value must survive re-renders but the screen does not depend on it. Changing a ref does not trigger a render, and that is the entire distinction.

Two legitimate uses: reaching a DOM node for something React has no declarative equivalent for — focus, scroll, measurement, media playback — and holding a mutable value like a timer id.

A good concrete example is the cart's server-side id:

/*
 * A ref, not state: the persist effect needs the CURRENT cart id without re-running every time
 * the id changes (which would cause an extra PUT).
 */
const cartIdRef = useRef<string | null>(cartIdStore.get());

As state, the id would belong in the persist effect's dependency array, so setting it would re-run the effect and issue a second PUT. Nothing on screen displays the id, so it is genuinely not display state.

The trap to name: reading or writing a ref during render is a bug. React may render twice, discard the result, or pause. Refs are touched in event handlers and effects.

12. Two components call the same custom hook. Do they share state?

No — and this is the question that reveals whether someone understands what a hook is. A custom hook is a function that calls other hooks. Calling it twice creates two independent sets of hook slots, in two different components.

Hooks share logic, never state. If two components must see the same value, that is context (or a store), and the usual shape is both together: a provider owning the state and a custom hook consuming it:

export function useCart(): CartContextValue {
  const context = useContext(CartContext);
  if (context === undefined) {
    throw new Error('useCart must be used inside a <CartProvider>');
  }
  return context;
}

Four lines doing three jobs: hiding the context object so the implementation stays private, narrowing CartContextValue | undefined to CartContextValue so no consumer needs a null check, and turning a missing provider into a sentence instead of "cannot read properties of undefined".

Context

13. How does Context actually work, and what problem does it solve?

It solves prop drilling — threading a value through five components that do not use it, to reach the sixth that does. The cart is needed by the navbar badge, the menu page, the drawer and checkout, and those sit in four different branches of the tree.

Three pieces, always:

// 1. Create — outside any component.
const CartContext = createContext<CartContextValue | undefined>(undefined);

// 2. Provide — a component that owns the state and publishes it.
export function CartProvider({ children }: { children: ReactNode }) {
  const [state, dispatch] = useReducer(cartReducer, initialState);
  const value = useMemo(() => ({ /* … */ }), [/* … */]);
  return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
}

// 3. Consume — anywhere below the provider.
const { totals } = useContext(CartContext);

Two things a senior answer gets right about the mechanism.

Context does not hold state — it distributes it. The provider component owns the state, with ordinary useState or useReducer; context is only the delivery mechanism. People describe it as "global state" and that framing causes most of the confusion around it.

It is a lookup up the tree, not a global registry. useContext walks up from the calling component and takes the value from the nearest matching provider above it. That is what makes nested providers work — you can wrap a subtree in a second provider to override the value for just that branch, which is how theming works.

14. What should you pass as createContext's default value?

Two misconceptions to clear up. It is not the initial value — the provider's value prop is that. The default is used only when a component reads the context with no provider above it at all, which in a correctly assembled app never happens.

So the temptation is to pass something plausible-looking, and that is the trap:

// Tempting, and wrong: a component rendered outside the provider silently gets an empty cart that
// never updates, and you debug the symptom for an hour instead of the cause.
const CartContext = createContext<CartContextValue>({ items: [], totals: emptyTotals, /* … */ });

// `undefined` as the default is deliberate — it makes the mistake detectable.
const CartContext = createContext<CartContextValue | undefined>(undefined);

Pass undefined, and put the guard in the custom hook that wraps it — which is also what removes | undefined from the type so no consumer needs a null check:

export function useCart(): CartContextValue {
  const context = useContext(CartContext);
  if (context === undefined) {
    throw new Error('useCart must be used inside a <CartProvider>');
  }
  return context;
}

The failure this catches is a real one: a provider accidentally mounted below the component that needs it, or a component moved outside the tree during a refactor. Without the guard you get an inert value that never updates, which looks like a state bug anywhere except where it is.

15. When is composition the better answer than context?

More often than people expect, and this is a strong differentiator. Context is not the only way to avoid passing a prop through the middle — you can also pass the rendered element down instead of the data it needs:

// Drilling: Layout does not use `user`, it just carries it.
<Layout user={user}>
  <Dashboard />
</Layout>

// Composition: Layout takes finished markup and never learns about `user` at all.
<Layout sidebar={<UserPanel user={user} />}>
  <Dashboard />
</Layout>

The second version has no provider, no context, no re-render fan-out — and the value is resolved where it is already in scope. When the "drilling" is one or two intermediate layout components, this is almost always the better tool.

Context earns its place when the consumers are many, scattered and unknown to the parent. Nobody can pass the cart down to the navbar badge as an element, because the navbar is a sibling of the routes, not a child of the menu page.

The other half of the answer: two levels is not prop drilling, it is just props. This app deliberately does both — the cart contents are in context, but whether the drawer is open is plain lifted state, because exactly two components care:

// The drawer's open/closed state lives here because both the navbar (which opens it) and the
// drawer itself need it. The cart CONTENTS are in context; this piece of purely-visual state
// is not worth putting there.
const [cartOpen, setCartOpen] = useState(false);

16. What belongs in context, and does provider nesting order matter?

Good candidates share a shape: read by many components, written by few, and changing at a rate the consumers can live with. The signed-in user, a theme, a locale, a design-system config, a shopping cart. Bad candidates: transient UI state that two components share (lift it), form field values (local), and — the big one — server data, which wants caching, deduplication, retries and revalidation, i.e. a query library rather than a provider.

Order matters as soon as one provider consumes another. In this app CartProvider calls useMenu(), because a saved cart line stores only ids and re-pricing it needs the catalogue:

export function CartProvider({ children }: { children: ReactNode }) {
  const [state, dispatch] = useReducer(cartReducer, initialState);

  // The catalogue is needed to rebuild a saved line's base price and crust surcharge.
  const { products, crusts, loading: menuLoading } = useMenu();
  /* … */
}
<AuthProvider>
  <MenuProvider>
    <CartProvider>        {/* must be INSIDE MenuProvider */}
      <ToastProvider>
        <App />
      </ToastProvider>
    </CartProvider>
  </MenuProvider>
</AuthProvider>

Swap those two and the app throws "useMenu must be used inside a <MenuProvider>" at startup — which, thanks to the guard from question 14, names the problem instead of surfacing as undefined somewhere unrelated. Where there is no such dependency, nest for readability: longest-lived outermost.

One more thing worth volunteering: where you mount a provider is a bundling decision too. This app's Redux <Provider> sits inside the lazily-loaded AdminLayout rather than in main.tsx, which keeps all 37 kB of store and slices out of the bundle every customer downloads.

17. Why do people say "context causes performance problems", and are they right?

Partly. Every component consuming a context re-renders when that context's value changes, and context compares the value by reference. So the actual problem is almost always this:

// A brand-new object every render, so every consumer re-renders every time —
// even when nothing they read has changed.
<CartContext.Provider value={{ items, totals, addItem, removeItem }}>

Two fixes, and a senior answer gives both.

Memoise the value, and every function inside it:

const addItem = useCallback((input: AddItemInput) => { /* … */ }, []);

const value = useMemo<CartContextValue>(
  () => ({ items: state.items, totals: calculateTotals(state.items, state.orderType), addItem, /* … */ }),
  [state.items, state.orderType, addItem /* … */],
);

Split contexts by how often they change. This app has four providers rather than one — auth changes on sign-in, the menu changes once, the cart changes constantly, toasts change constantly and briefly. A single AppContext would mean every cart click re-rendering everything that only cares who is signed in.

The honest caveat: context has no selector API, so a consumer cannot subscribe to one field of a value. That is a genuine limitation, and it is one of the reasons stores exist.

Architecture

18. When would you add Redux to an app that already has context and reducers?

A reducer inside a context provider is already Redux's architecture, so the answer has to be about what the library adds beyond that:

  • State that outlives the components showing it. The strongest one. In this app the admin reports were held in useState, so switching tabs and back threw the report away and refetched it. Store state is not owned by a component, so the tab comes back instantly.
  • Per-selector subscriptions, so a component re-renders for the slice it reads and not for unrelated ones.
  • Modelled asynccreateAsyncThunk gives you the pending/fulfilled/rejected triple instead of writing loading and error flags by hand.
  • DevTools. Every action with its payload, the state diff it produced, and the ability to step backwards. React DevTools shows current state, not the sequence that produced it.

And what it costs: two dependencies, more indirection, and the requirement that actions be serialisable — which in this app meant flattening ApiError at the edge, because Redux Toolkit serialises a thrown error down to name/message/stack and drops the structured body the forms needed.

The answer that lands best names the modern caveat: much of what used to go in Redux was server data, and server data has better tools now. Reach for a store for genuinely shared, long-lived, client-side state — not because the app is "big".

19. What do error boundaries not catch?

Event handlers, async code, errors thrown inside the boundary itself, and server rendering. Which is most of what actually goes wrong — a failed request is far more common than a component throwing during render.

So a real application has two systems. Expected failures — the network is down, the password is wrong — are modelled as state and rendered. Error boundaries are for the unexpected: the bug you did not know you had.

Two more things a senior answer includes. They still require a class component, because there is no hook equivalent of componentDidCatch — so even a fully modern codebase keeps one. And placement is the whole benefit: a boundary around the routed content leaves the navbar, cart drawer and footer alive when a page crashes, whereas one at the root catches everything and therefore loses everything.

20. Where do you split your bundle, and what goes wrong if you overdo it?

At route boundaries, and specifically routes a given visitor is unlikely to visit — plus heavy single-screen libraries (charts, editors, maps) and anything behind a permission most users lack.

Real numbers from this app's build make the case:

dist/assets/store-z0RVAOPD.js               36.54 kB │ gzip:  12.84 kB   ← Redux, admin only
dist/assets/index-B9HlveUY.js              350.46 kB │ gzip: 109.21 kB   ← what a customer downloads
dist/assets/AdminReportsPage-b0f8Qhta.js   353.73 kB │ gzip:  98.69 kB   ← charts, admin only

Roughly 430 kB never reaches a customer. Note why Redux ended up in a lazy chunk: <Provider store={store}> is mounted inside the lazily-loaded AdminLayout rather than in main.tsx. Where you mount a provider is a bundling decision as well as an architectural one — that is a good detail to volunteer.

What goes wrong when overdone: splitting something needed immediately adds a network round trip to the critical path, and splitting many small components produces a swarm of tiny requests. The two practical failure modes are a visible pause on first navigation — fixed by preloading on hover — and a chunk 404 after a deploy while someone has the old page open, which is why the Suspense boundary needs an error boundary outside it.

Performance

21. When does memo do nothing?

Whenever a prop is a new reference on every render, which is three cases and one underlying cause:

<ProductCard product={product} onSelect={(p) => setSelected(p)} />   // inline arrow
<ProductCard product={product} options={{ compact: true }} />        // object literal
<ProductCard product={product} badges={product.tags.map(toBadge)} /> // array built in render

In each case the shallow comparison fails every time, so memo adds a comparison and saves nothing. memo and useCallback are a pair — the real menu page wraps its handler precisely so the fourteen memoised cards actually hit:

const handleSelect = useCallback((product: Product) => {
  setSelectedProduct(product);
}, []);

Two more points worth making. useCallback on a handler passed to a plain DOM element is pure waste, because nothing downstream compares it. And composition is often the better fix than memoisation — moving state down, or passing the expensive subtree as children so it is not re-created, solves the same problem structurally.

22. A page feels slow. How do you find out why?

The answer should be a method, not a list of hooks.

  1. Reproduce and profile. React DevTools Profiler, record the interaction, look at the flamegraph: what re-rendered, how many times, and how long each took. The "why did this render?" setting names the trigger.
  2. Separate the two costs. Many cheap re-renders is a different problem from one expensive render. The first is a memoisation or state-placement problem; the second is an algorithm or a too-large list.
  3. Check it is React at all. Very often it is not — an unoptimised image, a blocking request, a 2 MB bundle, a layout thrash. The browser's Performance panel and the network tab come before any useMemo.
  4. Fix the cause, then re-measure. Structural fixes first — move state down, paginate or virtualise a long list, split the bundle. Memoisation last, because it is the one that adds code without changing the shape of the problem.

A candidate who opens with "I'd wrap it in useMemo" has answered the wrong question. The biggest wins are usually a list that should be paginated, an image that should be smaller, or a bundle that should be split.

Modern React

23. What changed in React 19 that affects how you write code?

The ones that come up:

  • ref is an ordinary prop. forwardRef is no longer needed for function components. Existing code still works.
  • ReactDOM.render is gone. Deprecated in 18, removed in 19 — the entry point is createRoot from react-dom/client. This is what "ReactDOM.render is not a function" means.
  • ActionsuseActionState, useFormStatus and useOptimistic — which handle the pending/error/optimistic triad that everyone hand-rolls around form submission.
  • The use hook, which reads a promise or a context and, unlike every other hook, may be called conditionally.
  • Cleanup functions from callback refs, and better hydration error messages.
  • The React Compiler — optional, and where enabled it memoises automatically, which makes most hand-written useMemo/useCallback/memo unnecessary. It relies on your components being pure and your state immutable, which is why those rules matter more rather than less.

24. Explain Server Components, and the concurrent features you have actually used.

Server Components render on the server and send a description of the UI rather than JavaScript. They can read a database or the filesystem directly, and they ship zero bundle. The cost is the boundary: they have no state, no effects and no event handlers, and anything crossing from server to client must be serialisable — so a function cannot be passed as a prop. 'use client' marks where interactivity resumes. In practice they need a framework; a plain Vite + React app does not have them.

Concurrent features are about keeping the app responsive while React works:

  • useTransition marks an update as non-urgent, so typing stays responsive while an expensive filtered list re-renders behind it, and gives you an isPending flag.
  • useDeferredValue is the same idea applied to a value rather than a setter — render the previous result while the new one is computed.
  • Suspense declares "this subtree is not ready", which is what makes both of the above composable. Most people first meet it as the partner to lazy.

The honest close is worth saying out loud: on a normal CRUD application you may never reach for useTransition, and reaching for it before profiling is a smell. Knowing what problem it solves is the point.

How to use this list

If you are preparing, the highest-value work is not memorising these — it is being able to point at code you have written and say why. Questions 1, 4, 5, 8 and 13 are the load-bearing ones; nearly every other React question reduces to one of them.

If you are interviewing someone, the follow-up is where the signal is. "Why is that a problem?" and "when would you not do that?" separate understanding from recall far better than another question does.

The rest of this track starts at Get Started.