Skip to the solution
HardFrontend6 min read

What do startTransition and useTransition actually do under the hood?

520 views

The problem

Question presented to candidate: "You wrap a state update in startTransition. What actually changes about how React renders it?"

What a strong answer should cover:

  • A transition marks an update non-urgent. It does not make the work faster — it changes when and at what priority the work is done.
  • The observable effect is a split: the urgent update commits on its own first, and the transition update commits in a second render.
  • That second render is interruptible. If a new urgent update arrives while it is in progress, React abandons the partial work and starts over with the newer state.
  • The old UI stays on screen while the transition renders — no fallback, no blank space, which is the difference from a plain Suspense boundary.
  • useTransition gives you an isPending boolean; startTransition imported from React does not, and can be called outside a component.
  • Never wrap the controlled value of an input in a transition — the input must update urgently or typing feels broken.
  • It changes priority, not cost. If a render takes 300ms it still takes 300ms; it just no longer blocks the keystroke.

Clarifying questions expected:

  • "Which part of the update is the user waiting on directly?" — that part stays urgent.
  • "Is the slowness the render itself, or fetching data?" — a transition helps the first, not the second.

Code / implementation expected: Optional. Showing which setState goes inside the transition and which stays outside is the substance.

  • react
  • concurrent
  • performance

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

6 min read · 9 sections

Try it in the playground

Open any example in the playground to run and change it.

ReactThe same slow filter, with and without a transition
import { useState, useTransition } from "react";

const ROWS = Array.from({ length: 2000 }, (_, i) => "Item " + i);

function slowFilter(q) {
  // Deliberately expensive so the difference is visible, not theoretical.
  const out = [];
  for (const r of ROWS) {
    let acc = 0;
    for (let i = 0; i < 200; i++) acc += i % 7;
    if (acc >= 0 && r.includes(q)) out.push(r);
  }
  return out;
}

// ❌ Both updates batch into ONE render, so the typed character cannot appear
//    until the 2000-row filter has finished. The input feels stuck.
function WithoutTransition() {
  const [text, setText] = useState("");
  const [result, setResult] = useState({ query: "", rows: ROWS.length });
  return (
    <Panel
      title="❌ no transition"
      value={text}
      pending={false}
      result={result}
      onChange={(v) => {
        setText(v);
        setResult({ query: v, rows: slowFilter(v).length });
      }}
    />
  );
}

// ✅ Two renders per keystroke. The first shows the typed character with the
//    OLD result still on screen and isPending true; the second commits the
//    filtered rows. Watch the "showing results for" line lag behind.
function WithTransition() {
  const [isPending, startTransition] = useTransition();
  const [text, setText] = useState("");
  const [result, setResult] = useState({ query: "", rows: ROWS.length });
  return (
    <Panel
      title="✅ transition"
      value={text}
      pending={isPending}
      result={result}
      onChange={(v) => {
        setText(v);                                     // urgent: the input
        startTransition(() =>                           // non-urgent: the list
          setResult({ query: v, rows: slowFilter(v).length }));
      }}
    />
  );
}

function Panel({ title, value, pending, result, onChange }) {
  const stale = value !== result.query;
  return (
    <div style={{ border: "1px solid #ddd", borderRadius: 8, padding: 12, marginBottom: 12 }}>
      <strong style={{ fontSize: 13 }}>{title}</strong>
      <input
        value={value}
        onChange={(e) => onChange(e.target.value)}
        placeholder="type here"
        style={{ width: "100%", margin: "6px 0" }}
      />
      <div style={{ fontSize: 13, opacity: pending ? 0.5 : 1 }}>
        showing <strong>{result.rows}</strong> rows for{" "}
        <code>{JSON.stringify(result.query)}</code>
        {pending && <em> — updating…</em>}
        {stale && !pending && <em> — stale</em>}
      </div>
    </div>
  );
}

export default function App() {
  return (
    <div style={{ padding: 24, fontFamily: "system-ui", lineHeight: 1.6, maxWidth: 520 }}>
      <WithoutTransition />
      <WithTransition />

      <p style={{ fontSize: 13, color: "#666" }}>
        Type quickly in both. In the first, the character itself does not
        appear until the filter finishes — one render, and you wait for it. In
        the second the character lands immediately while the results line still
        shows the PREVIOUS query, dimmed, until the second render commits.
        That visible lag is the transition doing its job.
      </p>
    </div>
  );
}

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 100 of 119 in React.js

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

All React.js questions
Up nextHard

When would you use `useLayoutEffect` instead of `useEffect`?

Continue