open atlas
↑ Back to track
React, zero to senior RCT · 05 · 03

Transitions and useDeferredValue: making slow renders interruptible, not faster

Transitions put updates in urgent and background lanes: the urgent commit lands first, the heavy render is interruptible per keystroke. useDeferredValue mirrors it for received values; with Suspense old UI holds, no fallback flash. One giant component cannot be interrupted.

RCT Senior ◷ 19 min
Level
FoundationsJuniorMiddleSenior

The product search team had read about concurrent React, so when the catalog filter started dropping keystrokes — 280 ms of render per character across 20,000 product cards — the fix seemed obvious: wrap the state update in startTransition. They wrapped setQuery(e.target.value) and shipped. Nothing improved. Worse: now the input itself lagged, because the text in the box was driven by query, and the transition had politely deprioritized the very update that echoes the user’s keystroke. The senior on the team spotted it in review the following week: a transition does not make work cheaper, it splits work into lanes — and they had pushed the urgent lane’s only job into the slow lane. The fix was two states: setText(value) stays urgent so the input echoes instantly, startTransition(() => setQuery(value)) carries the 280 ms list render in the background, restartable on every new keystroke. Typing went glass-smooth while results trailed by a beat — the 280 ms render still happened, the user just stopped paying for it with their keystrokes.

Two lanes: urgent and non-urgent

startTransition does not speed anything up, debounce anything, or move work off the main thread. It changes the priority of the state updates inside its callback: they become transitions — non-urgent updates that React renders in the background while the screen keeps showing the last committed UI. Urgent updates (typing, clicking, anything not wrapped) interrupt that background render: React abandons the half-built work-in-progress tree, commits the urgent update first, then restarts the transition render from the latest state. During the background render React also yields to the main thread between components (roughly every 5 ms), so the browser can paint and handle events mid-render instead of freezing for the full 280 ms.

function CatalogSearch({ products }) {
  const [text, setText] = useState("");        // urgent: echoes the keystroke
  const [query, setQuery] = useState("");      // transition: drives the heavy list
  const [isPending, startTransition] = useTransition();

  function handleChange(e) {
    setText(e.target.value);                   // commits immediately
    startTransition(() => {
      setQuery(e.target.value);                // renders in background, interruptible
    });
  }

  return (
    <>
      <input value={text} onChange={handleChange} />
      <div style={{ opacity: isPending ? 0.6 : 1 }}>
        <ProductGrid products={products} query={query} />
      </div>
    </>
  );
}

The split is the whole design: the urgent state’s render is tiny (one input), so it commits within a frame or two; the expensive tree depends only on the transition state, so its render is interruptible and restartable. isPending gives you an honest loading signal for the stale-but-visible content. The Hook team’s first attempt failed because they had one state doing both jobs — wrapping it in a transition made the input echo non-urgent too. Rule of thumb: the value the user is directly manipulating must never be inside the transition.

Quiz

A controlled search input drives an expensive results list via one state value. A developer wraps the whole setState in startTransition and the input starts lagging. What happened?

useDeferredValue: deferring values you do not own

useTransition requires owning the setState call. Often you do not — the prop comes from a parent, a router, or a library hook. useDeferredValue(value) is the receiving side’s equivalent: it returns the previous value while a background re-render with the new value is in flight, letting you keep an expensive subtree on the stale value without touching the producer.

function SearchResults({ query }) {           // query updates urgently upstream
  const deferredQuery = useDeferredValue(query);
  const isStale = deferredQuery !== query;
  const grid = useMemo(
    () => <HeavyGrid query={deferredQuery} />,
    [deferredQuery]
  );
  return <div style={{ opacity: isStale ? 0.6 : 1 }}>{grid}</div>;
}

Mechanically: when query changes, React first re-renders urgently with deferredQuery still holding the old value — this render must be cheap, which is why the expensive subtree is memoized on deferredQuery and bails out — then starts a background render where deferredQuery catches up. That background render is a transition: interruptible, restartable on the next change. The memoization is not optional decoration here; without it the urgent render re-renders HeavyGrid with the old value anyway, and you pay the full cost twice per keystroke. Choosing between them: own the setter → useTransition (plus isPending for free); receive the value → useDeferredValue (compare values for staleness).

Transitions and Suspense: no fallback flash

The second superpower is what transitions do to Suspense. An urgent update that suspends — say, changing a tab makes a component throw a promise for data it does not have — hides the already-visible content behind the nearest fallback: the page you were reading is replaced by a spinner. The same update inside a transition behaves differently: React keeps the current, committed UI on screen, renders the suspended tree in the background, and commits only when the data is ready. No fallback flash; the old tab stays visible (use isPending to dim it). This is why router navigations in modern frameworks are wrapped in transitions by default — clicking a link should not blank a working page into a spinner. The exception is precise: only already revealed content is protected. A Suspense boundary mounting for the first time inside the transition still shows its fallback — there is no previous UI to hold.

When transitions do nothing

Transitions buy you interruptibility between components, applied to render-phase work. Three situations defeat them. One giant component: React yields between component renders, never inside one — a single component that takes 200 ms (a monolithic chart building 10,000 SVG elements in one function) blocks the lane regardless of priority; break it into smaller components or virtualize. The cost is not render: if the slowness is a synchronous filter over 100k items inside the event handler itself, or layout thrash in effects, the work happens outside the render phase that transitions schedule. One cheap commit: if the update was a 20 ms render committing once, there is nothing to slice — wrapping it changes nothing observable. A transition is a scheduler hint, not a speedup: total CPU work is identical or slightly higher (abandoned renders are paid for and thrown away — that is the deal: wasted watts for responsive input).

Quiz

Clicking a tab swaps in a component that suspends while fetching. Without a transition the whole page is replaced by a spinner; inside startTransition it is not. What exactly does the transition change?

Order the steps

Order the steps to make a slow 280 ms list render non-blocking with startTransition:

  1. 1 Split into two state variables: one for the input text (urgent), one for the query driving the heavy list (transition) — so the echo and the render travel different lanes
  2. 2 In the change handler, call setText(value) directly so the input echo commits within one frame, keeping the UI immediately responsive
  3. 3 Wrap setQuery(value) in startTransition(() => ...) to mark the list render as non-urgent and interruptible by subsequent keystrokes
  4. 4 Read isPending from useTransition and apply a visual cue (e.g. opacity: 0.6) to the stale list so users know results are updating
  5. 5 Verify in the Profiler that keystrokes no longer block the urgent lane — the list render is still 280 ms but it no longer stalls input echo
Recall before you leave
  1. 01
    Walk through what React does, frame by frame, when a user types two characters quickly into an input whose handler does setText urgently and setQuery inside startTransition.
  2. 02
    Name the three situations where wrapping an update in startTransition changes nothing, and what the actual fix is in each.
Recap

Concurrent React’s answer to expensive updates is not making them faster but making them interruptible. startTransition labels the state updates in its callback non-urgent: React renders them in the background against a work-in-progress tree, yields to the main thread between components roughly every 5 ms, lets any urgent update interrupt — abandoning the half-built tree and restarting from the newest state — and commits only a finished result. The screen always shows the last committed UI, and isPending reports the gap. The cardinal mistake is wrapping the value the user is directly manipulating: a controlled input driven by transition state echoes keystrokes at background priority, which is the lag the catalog team shipped. Split the state — urgent for the echo, transition for the heavy tree. When the changing value arrives as a prop instead of your own setState, useDeferredValue is the receiving-side mirror: it holds the old value through a cheap urgent render (memoize the expensive subtree on the deferred value, or the trick buys nothing) and catches up in a background render. With Suspense, transitions remove the fallback flash: an update that suspends keeps already-revealed content on screen and commits when data arrives — the reason framework routers wrap navigations in transitions — though boundaries mounting for the first time still show their fallback. And know the limits: yields happen between components, so one monolithic 200 ms component blocks any lane; handler-side computation and effects run outside the scheduled render; a single cheap commit has nothing to slice. Total CPU goes up, not down — abandoned renders are the price of an input that never stutters. Now when you see a controlled input lagging while a list catches up, you will reach for the two-state split before anything else.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 6 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

Apply this

Put this lesson to work on a real build.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.