Blog · 2026-01-18 · 14 min read

React Interview Questions for 2026

The React questions that separate mid from senior, keys and reconciliation, useEffect ordering, memoisation, context versus stores, concurrency, Server Components and hydration.

React interviews are not about recalling API surface. They are about whether you understand why the API has the shape it does: when a component re-renders, what a dependency array actually compares, and why memoisation usually is not the answer. The answers below assume React 19 with the stable hooks, and note version-specific details where they matter.

What problem do keys solve in lists?

Keys let React match each element in a new render to the element from the previous render. Without a stable identity, React falls back to positional matching and will happily reuse the wrong component instance, taking its state and DOM with it.

The classic breakage is an index key on an editable list:

function TodoList({ todos }) {
  return (
    <ul>
      {todos.map((todo, i) => (
        <li key={i}>
          <input defaultValue={todo.text} />
        </li>
      ))}
    </ul>
  );
}

Render ['write tests', 'ship it']. The user edits the first input, so its DOM value is now write tests!, but React's state for that item is still write tests. Now the parent prepends an item and the list becomes ['call Ana', 'write tests', 'ship it']. React matches key={0} to key={0}, reuses the existing <li>, and leaves the user's typed text in place. The first row now displays call Ana while the input reads write tests!. State has silently migrated to the wrong record.

The fix is to key by something that belongs to the data, not to the position:

todos.map((todo) => <TodoRow key={todo.id} todo={todo} />)

Index keys are acceptable only when the list is genuinely append-only and never reordered, filtered, or spliced in the middle. They are not acceptable for anything a user can reorder, search, or paginate.

Explain useEffect and its dependency array

useEffect schedules a side effect after React commits. The dependency array controls when the cleanup-and-rerun cycle happens: React compares each dependency against its previous value with Object.is, and if any differ it runs the previous cleanup and then the effect again. An empty array means "run on mount and clean up on unmount only". Omitting the array means run after every render, which is almost never what you want.

The ordering question is the one that catches people. On an update React does this:

  1. Render phase. In concurrent mode this can be interrupted, thrown away, and restarted, so it must stay pure.
  2. Commit phase. React mutates the DOM, then runs useLayoutEffect.
  3. Browser paint.
  4. useEffect callbacks, flushed asynchronously after paint.
useEffect(() => {
  console.log('mount');
  return () => console.log('cleanup');
}, []);        // runs once

useEffect(() => {
  console.log('every render');
});              // no array: every render

Within a single component, effects fire in declaration order. When several effects change in the same commit, React runs all the cleanups first and then all the new effects, so a cleanup always sees the state of the previous commit. Use useLayoutEffect only when you must read or write layout before paint, such as measuring a node to position a tooltip. It blocks paint, so treat it as an escape hatch.

When is useMemo or useCallback actually worth it?

Most of the time it is not. The real reason to memoise is referential equality, not computation cost. React.memo compares props shallowly with Object.is, so a child wrapped in memo is re-rendered whenever it is handed a new object, array, or function. If you pass an inline arrow or a literal array, memoisation is defeated no matter how expensive the child is.

// Every render creates a new object, so Row always re-renders.
<Row user={{ id, name }} onSelect={() => pick(id)} />

So useMemo is worth it in two situations: the computation genuinely is expensive enough to measure, or you need a stable reference to keep a dependency array or a memoised child from firing. Outside those cases it costs an allocation and an extra comparison on every render, and it adds noise that makes the component harder to read.

React also ships an opt-in compiler that inserts memoisation automatically. It is a separate build plugin rather than part of the react package, so it is opt-in at the bundler step rather than by version. That is the direction the ecosystem is taking, which is a decent argument for keeping hand-written useMemo calls rare and reserved for cases you have measured.

How does React's diffing work?

React compares two element trees produced by the same component. Three things decide what happens to a node.

First, element type. If the tag or component differs, React does not try to patch it. It unmounts the old subtree, destroys its state, and mounts a fresh one. Changing a single letter in a tag name is a full remount, which is why conditional rendering that switches between two similar components loses state.

Second, key. Keys let React find an element in the old tree by identity rather than by position, which is what allows it to move a node instead of rebuilding it.

Third, props. For nodes that survive both checks, React diffs props and applies only the differences to the DOM.

The algorithm is deliberately a heuristic rather than a general tree diff. It assumes a stable tree shape with stable keys and stops descending once it finds a type mismatch. That assumption is the whole reason keys matter: if the heuristic's precondition is violated, it is not merely slower, it produces wrong output. If you need a genuinely minimal diff over arbitrary trees, that is a different data structure and React is not it.

Controlled versus uncontrolled components

A controlled component has no internal state for the value. React state is the single source of truth, and the component reports changes through onChange, typically onChange={e => setState(e.target.value)}. Everything downstream can render off that state, which makes validation, conditional UI, and formatting straightforward.

An uncontrolled component lets the DOM own the value. React sets the initial value through defaultValue and reads the current value through a ref. The form submits, but you cannot react to every keystroke without adding a listener.

Default to controlled. Reach for uncontrolled when the form is large and you only need the values on submit, when you are integrating with a third-party widget that insists on owning its DOM, or for file inputs, where the value is not a readable string at all.

Sharing state across distant components

There are three options and they are not interchangeable.

Lifting state to the nearest common ancestor is the cheapest thing that works, and it is underused. If two components are ten levels apart but only that component uses the value, the tree is telling you the state is in the wrong place. Colocate first.

Context is for values that change rarely and that many components need: theme, locale, the current user, a router. It removes prop drilling without adding a dependency.

An external store such as Zustand, Redux, or Jotai is for high-frequency shared state. The reason is that Context is not a subscription system, and this is the precise thing interviewers are testing.

When a Provider's value changes identity, React re-renders every component that reads that context. React.memo does not save them, because the update originates above them and context updates deliberately bypass memoisation.

// Two contexts in one tree. Changing the theme re-renders every
// component that calls useContext(UserContext) too, because App
// re-renders and both providers get new element children.
<ThemeContext.Provider value={theme}>
  <UserContext.Provider value={user}>
    <Shell />
  </UserContext.Provider>
</ThemeContext.Provider>

Splitting into two contexts helps only if the two values do not change together. The general fix is a selector-based store: each component subscribes to the slice it needs, and the store notifies only the subscribers whose selected slice changed. Zustand's useStore(selector) and Redux's useSelector with an equality function both do this. For high-frequency values like cursor position or a live dashboard, that is the only approach that scales.

How do you prevent unnecessary re-renders?

Measure first with the React DevTools Profiler. Every answer that skips this step is guessing. The profiler commit view tells you which component rendered and why, and the flamegraph shows how long it took, so you can distinguish a cheap render that runs too often from an expensive render that runs rarely.

Then work down a list: move state closer to where it is used so fewer components subscribe to it; stabilise props that cross a memo boundary; split context by change frequency; give each list row a stable key and a memo boundary so one row changing does not re-render the list; and virtualise long lists. Virtualisation is the only technique that reduces the number of component instances, which is why it wins when the list is the actual problem.

Data fetching in a modern app

Modern options are React Query or SWR, framework-level fetching such as a server component or a framework data loader, and raw useEffect. The first two give you a cache, request de-duplication, retries with backoff, background refetch, and stale-while-revalidate. Writing that yourself is the mistake.

Raw useEffect fetching is error-prone for a specific reason: it does not cancel anything. This component renders wrong results under ordinary typing speed:

function Search({ query }) {
  const [results, setResults] = useState([]);

  useEffect(() => {
    let current = true;
    fetch(`/api/search?q=${encodeURIComponent(query)}`)
      .then((r) => r.json())
      .then((data) => {
        if (current) setResults(data);   // flag, not cancellation
      });
    return () => { current = false; };  // stale response still consumes bandwidth
  }, [query]);

  return <List items={results} />;
}

Type a, then ab. The a request is still in flight. If it resolves after the ab request, the current flag prevents the state write, so this version is not visibly broken, but the request is still running and its body is still parsed. On a slower network, or with three requests in flight, you are one refactor away from shipping stale results.

The correct fix is to abort the superseded request:

useEffect(() => {
  const controller = new AbortController();
  fetch(`/api/search?q=${encodeURIComponent(query)}`, { signal: controller.signal })
    .then((r) => r.json())
    .then(setResults)
    .catch((err) => {
      if (err.name !== 'AbortError') throw err;
    });
  return () => controller.abort();
}, [query]);

Even that is a lot of hand-written machinery for something a cache library gives you for free, along with request de-duplication so that ten components asking for the same key issue one request.

Portals

createPortal(children, domNode) renders React elements into a DOM node outside the parent's DOM position while keeping them in the React tree. Context still flows through, and events bubble through the React tree rather than the DOM tree.

You need one when a child must escape a parent's layout containment: a modal that must render above an overflow: hidden container, a tooltip that would be clipped, or a dropdown inside a scrolling table. Rendering a modal at the top of the tree and using a portal at the leaf is the usual shape, because you still want the modal logically near the component that owns its state.

The accessibility work is not optional. Move focus into the dialog on mount, trap it while open, restore it on close, and mark the rest of the app aria-hidden.

Error boundaries

An error boundary is a class component implementing getDerivedStateFromError and usually componentDidCatch. It catches errors thrown during rendering, in lifecycle methods, and in constructors of components below it, then renders a fallback instead of unmounting the whole tree.

class ErrorBoundary extends React.Component {
  state = { error: null };

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

  componentDidCatch(error, info) {
    reportToErrorTracker(error, info.componentStack);
  }

  render() {
    if (this.state.error) return <Fallback error={this.state.error} />;
    return this.props.children;
  }
}

It does not catch errors in event handlers, because those run after commit and outside React's call stack. It does not catch rejected promises or async errors in effects. It does not run during server rendering, where there is no component instance boundary in the same sense, and it does not catch errors thrown by the boundary's own render. Handle those with try/catch at the call site and report them yourself.

There is still no hook equivalent in React 19; error boundaries remain class-only, and react-error-boundary is a popular wrapper. Place them at meaningful boundaries rather than around every component: per route, per widget, around anything that renders untrusted or third-party content.

Suspense, lazy loading, and code splitting

lazy() turns a dynamic import() into a component that suspends until its chunk arrives, and Suspense provides the fallback.

import { lazy, Suspense, useState } from 'react';

const SettingsDialog = lazy(() => import('./SettingsDialog.js'));

export function App() {
  const [open, setOpen] = useState(false);
  return (
    <>
      <button type="button" onClick={() => setOpen(true)}>Settings</button>
      {open && (
        <Suspense fallback={<Spinner />}>
          <SettingsDialog />
        </Suspense>
      )}
    </>
  );
}

Two things to say out loud. Route-level splitting is the highest-value split, because it moves bytes off the critical path for the first paint; splitting every leaf component often adds more request overhead than it saves. And a Suspense boundary placed high in the tree makes the whole page show a spinner, which is a regression. Put boundaries low, around the region that is genuinely still loading.

React 18 and 19 concurrency

The concurrency features let React interrupt, restart, or defer work so that input stays responsive. useTransition marks an update as non-urgent:

function FilterableList({ items }) {
  const [query, setQuery] = useState('');
  const [filtered, setFiltered] = useState(items);
  const [isPending, startTransition] = useTransition();

  function onChange(event) {
    const next = event.target.value;
    setQuery(next);                       // urgent: the input must respond now
    startTransition(() => {
      setFiltered(expensiveFilter(items, next));  // non-urgent: may be interrupted
    });
  }

  return (
    <>
      <input value={query} onChange={onChange} />
      {isPending && <p role="status">Updating results</p>}
      <List items={filtered} />
    </>
  );
}

query updates immediately so the input stays responsive, while React continues showing the previous filtered result and reports isPending. Be precise about what this buys: it does not make expensiveFilter faster. It makes the resulting render interruptible and keeps it off the urgent path, so typing never queues behind a thousand-row re-render. That is why transitions are the right tool for filtering, sorting, and pagination, and the wrong tool for anything the user is waiting on directly.

useDeferredValue is the read-side equivalent: keep the urgent value in state, derive a deferred value from it, and render from the deferred one. It avoids the two-state-variable pattern above when you do not need to control the update yourself.

useSyncExternalStore (React 18 and later) is the supported way to subscribe to a store outside React, and it is what libraries use to avoid tearing when an external store updates during a concurrent render.

Server Components versus client components

A Server Component runs only on the server. It can be async, it can read your database directly, and it cannot use state, effects, event handlers, or browser APIs, because none of that exists where it runs. Client Components are the ordinary components, and they ship JavaScript to the browser.

In a framework that supports them, such as the Next.js App Router, a component is a Server Component unless it opts out:

// Server Component: fetches during render, ships no JS for the table itself.
export default async function ProductsPage() {
  const products = await db.products.list();
  return <ProductTable products={products} />;
}
'use client';

import { useState } from 'react';

export default function ProductTable({ products }) {
  const [sortBy, setSortBy] = useState('price');
  // event handlers and state live from here down
}

The practical rule is to push the 'use client' boundary as low in the tree as you can. Once a component is a client component, everything it renders is also client code, and props crossing the boundary from server to client must be serialisable, which rules out functions, class instances, and most non-plain objects. Server Components are a framework feature, not a react package export, so they are unavailable in a plain client-side Vite application.

What causes hydration mismatch, and how do you fix it?

Hydration is React attaching event handlers and its own state to server-rendered markup. If the markup the client computes on the first render differs from what the server sent, React throws away the server HTML and re-renders on the client, which costs you the benefit of server rendering and causes a visible flash.

The usual causes are values that differ between server and client: Date.now(), Math.random(), typeof window checks, localStorage, viewport width, and locale or timezone-dependent formatting. The other cause is invalid HTML nesting, where the browser's parser repairs the structure and React's virtual tree does not match it. A <div> inside a <p>, or a <div> inside another <div> that the parser closes early, both produce this.

Fix the causes rather than hiding the symptom. Do not paper over it with suppressHydrationWarning unless the mismatch is genuinely one text node and you have verified it is harmless. If you need client-only values, render the deterministic value first and upgrade after mount:

function RelativeTime({ iso }) {
  const [label, setLabel] = useState('just now');
  useEffect(() => {
    setLabel(new Date(iso).toLocaleString());
  }, [iso]);
  return <time>{label}</time>;
}

How would you test a React component?

Test what the user can observe, and mock as little as possible. With React Testing Library, query by role, label, or visible text, use userEvent rather than fireEvent so the events are realistic, use findBy for anything asynchronous, and stub the network with a mock service worker rather than mocking your own hook.

The trap is testing implementation details: asserting on a component's internal state name, calling a private helper, or matching a CSS class. Those break whenever you refactor without changing behaviour, which trains the team to avoid refactoring and tells you nothing about whether the feature works. The tests you actually want are: does the form reject an invalid submission with the right message, does the row disappear after the optimistic delete, does the disabled button actually prevent submission. Put pure logic in ordinary unit tests where it is cheap, keep component tests for user-visible flows, and leave end-to-end coverage for the critical path only.

Why streaming, and why a slow component must not block the page

Server rendering the whole page before sending any of it means the slowest component sets the time to first byte for everything. Streaming lets the server flush the shell first and stream slow parts as HTML as they resolve, wrapped in Suspense boundaries that the browser can already display a fallback for. The user sees a usable page sooner even when the total time is unchanged.

The reason a slow component must not sit above the boundary is that React streams in document order. Anything inside a Suspense boundary can arrive late; anything outside it is already sent. So a slow data fetch in your layout, above the page boundary, blocks the shell for everyone, while the same fetch inside a boundary degrades to a spinner in one region.

Related reading: the system design cheat sheet covers the server-side tradeoffs, and the SQL window functions post covers the analytics side of a data-heavy product.


Want the full set?

  • 360 questions, every one with a model answer
  • Hooks + rendering cheat sheet
  • Redux vs Zustand vs Context
  • Performance checklist

Recommended pack

React Developer Interview Pack

Hooks, the virtual DOM, state management patterns and render performance, with the tradeoffs interviewers probe.

Get this pack Free Frontend pack