Skip to main content

Transitions: startTransition and useTransition

What you'll learn

  • How to mark an update as non-urgent with startTransition
  • The two-render trick: urgent feedback commits now, heavy content follows when ready
  • useTransition and isPending: instant feedback with zero extra state
  • The killer rule: React refuses fallbacks that would hide already-visible content
  • Async actions: a pending state that spans an entire async operation

You now own the whole machinery: time slicing, priority lanes, discardable renders. One piece is missing, React assigns lanes by context, and it can't guess your intent. Only you know that a keystroke is urgent but the 5,000-row result list isn't. Transitions are how you say so. This is the chapter where the machinery becomes a superpower.

The split: urgent vs transition updates

Look at any interactive screen and you can sort its updates into two piles:

  • Urgent, the direct feedback of a physical interaction: the typed letter appearing, the pressed button lighting up, the hover highlight. If this lags, the app feels broken.
  • Transition, the result of the interaction: the new tab's content, the filtered list, the search results. If this lags a few hundred milliseconds, the app feels busy, which is fine.

Users are remarkably tolerant of content arriving late. They are not at all tolerant of an interface that ignores their fingers. Transitions let you spend the main thread accordingly.

startTransition: marking an update as non-urgent

Jargon: "transition". An update explicitly marked as interruptible and non-urgent. It renders in the transition lane (chapter 3): time-sliced, preemptible, and discardable if something more urgent arrives.

import { startTransition } from 'react';

startTransition(() => {
setTab('posts'); // this update is now non-urgent
});

Every update scheduled inside the callback goes to the transition lane. Everything outside stays urgent. Now let's feel the difference.

Demo: slow tabs, without a transition

Three tabs, each rendering a deliberately heavy panel (~300ms of stand-in work):

import { useState } from 'react';

function burnCpu(milliseconds) {
  const start = performance.now();
  while (performance.now() - start < milliseconds) {}
}

function SlowTabPanel({ tab }) {
  const rows = [];
  for (let i = 0; i < 300; i++) {
    burnCpu(1); // ~300ms total: stand-in for a heavy screen
    rows.push(<p key={i}>{tab} — row {i}</p>);
  }
  return <section>{rows}</section>;
}

export default function TabContainer() {
  const [tab, setTab] = useState('home');

  return (
    <div>
      <nav>
        {['home', 'posts', 'contact'].map((name) => (
          <button
            key={name}
            onClick={() => setTab(name)}
            style={{ fontWeight: tab === name ? 'bold' : 'normal' }}
          >
            {name}
          </button>
        ))}
      </nav>
      <SlowTabPanel tab={tab} />
    </div>
  );
}

What happens:

  1. You click posts. setTab('posts') schedules an urgent update, everything in a click handler is urgent by default.
  2. React starts an urgent render. But that render includes SlowTabPanel with the new tab: 300ms of synchronous work.
  3. The urgent render cannot be interrupted, so the main thread is blocked for 300ms.
  4. The button's bold highlight is part of that same render, so it can't paint either. Your click looks ignored.
  5. 300ms later, everything appears at once: highlight and content together.

The highlight freezing is the insult here. A 1-millisecond style change is held hostage by a 300-millisecond panel.

Demo: with startTransition: the two-render trick

Split the state: one urgent piece for the highlight, one transitioned piece for the heavy panel. (Full file again, so you can paste it directly.)

import { useState, useTransition, memo } from 'react';

function burnCpu(milliseconds) {
  const start = performance.now();
  while (performance.now() - start < milliseconds) {}
}

const SlowTabPanel = memo(function SlowTabPanel({ tab }) {
  const rows = [];
  for (let i = 0; i < 300; i++) {
    burnCpu(1);
    rows.push(<p key={i}>{tab} — row {i}</p>);
  }
  return <section>{rows}</section>;
});

export default function TabContainer() {
  const [tab, setTab] = useState('home');     // urgent: drives the highlight
  const [panel, setPanel] = useState('home'); // transition: drives the content

  function selectTab(next) {
    setTab(next); // urgent — highlight now
    startTransition(() => {
      setPanel(next); // non-urgent — content when ready
    });
  }

  return (
    <div>
      <nav>
        {['home', 'posts', 'contact'].map((name) => (
          <button
            key={name}
            onClick={() => selectTab(name)}
            style={{ fontWeight: tab === name ? 'bold' : 'normal' }}
          >
            {name}
          </button>
        ))}
      </nav>
      <SlowTabPanel tab={panel} />
    </div>
  );
}

What happens:

  1. You click posts. Two updates are scheduled: tab = 'posts' (discrete/urgent) and panel = 'posts' (transition lane).
  2. Render 1, urgent. tab is new, panel is still 'home'. This render is cheap: SlowTabPanel is wrapped in memo, and its prop didn't change, so React skips it entirely. Render 1 commits within a frame: the bold highlight moves to "posts" instantly. The old home content is still visible below.
  3. Render 2, transition. Now React renders with panel = 'posts'. This one includes the 300ms of rows, but it's time-sliced into 5ms pieces, interruptible, and off the critical path.
  4. If you click contact mid-render: the posts draft is discarded (chapter 4), and a fresh transition render starts for contact. Rapid clicking never queues up a backlog of heavy panels.
  5. Render 2 finishes and commits: the content swaps to the new tab.

Total CPU is roughly the same as before. The experience is transformed: every click is acknowledged in one frame, and the heavy content catches up when it can.

Note the quiet hero: memo. Without it, render 1 would re-run SlowTabPanel with the old props anyway, 300ms back in the urgent path. Deferred rendering only pays off if the expensive child can be skipped when its inputs haven't changed.

useTransition: isPending for free

Splitting state by hand works, but React can give you the feedback flag directly:

Jargon: "isPending". A boolean returned by useTransition that is true from the moment a transition is scheduled until it commits. It lets you render instant "working on it" feedback without tracking extra state.

import { useState, useTransition, memo } from 'react';

function burnCpu(milliseconds) {
  const start = performance.now();
  while (performance.now() - start < milliseconds) {}
}

const SlowTabPanel = memo(function SlowTabPanel({ tab }) {
  const rows = [];
  for (let i = 0; i < 300; i++) {
    burnCpu(1);
    rows.push(<p key={i}>{tab} — row {i}</p>);
  }
  return <section>{rows}</section>;
});

export default function TabContainer() {
  const [isPending, startTransition] = useTransition();
  const [tab, setTab] = useState('home');

  function selectTab(next) {
    startTransition(() => setTab(next));
  }

  return (
    <div>
      <nav>
        {['home', 'posts', 'contact'].map((name) => (
          <button
            key={name}
            onClick={() => selectTab(name)}
            style={{ fontWeight: tab === name ? 'bold' : 'normal' }}
          >
            {name}
          </button>
        ))}
        {isPending && <span> Loading…</span>}
      </nav>
      <div style={{ opacity: isPending ? 0.6 : 1 }}>
        <SlowTabPanel tab={tab} />
      </div>
    </div>
  );
}

What happens, the two-render mechanism:

  1. Click posts. setTab goes to the transition lane, and isPending flips to true as an urgent update.
  2. Render 1, urgent: tab is still 'home' (the transition update is excluded from this render), but isPending is true. Tiny diff: the "Loading…" badge appears and the old panel dims. Commits in one frame, your click is acknowledged instantly.
  3. Render 2, transition: tab = 'posts', isPending = false, plus the heavy panel. Sliced, preemptible, discardable.
  4. Render 2 commits: new content appears, badge disappears, dim clears, all in one consistent screen.

The killer rule: no downgrades

One more guarantee, and it's the one people remember. Suppose the transitioned panel suspends, it needs data that hasn't arrived (Suspense is next part; for now, picture a component that says "I can't render yet").

Jargon: "fallback". The placeholder UI (spinner, skeleton) a Suspense boundary shows while its content is waiting for data.

React's rule: if a transition render suspends, and committing the fallback would hide content that's already visible, React refuses to commit the fallback. It keeps showing the old screen until the data is ready, then commits the real new screen directly.

Applied to our tabs: you're looking at the home tab. You click posts; the posts panel needs data. React could show a big spinner where the home panel was, but that spinner is a downgrade from a perfectly good screen you were reading. So React doesn't. You look at the old tab for 300ms longer, and then the new tab appears fully formed. No flash, no layout jump, no spinner roulette.

Contrast with initial load: on first paint there is nothing on screen. A fallback is strictly better than a blank page, so React shows it happily. Same mechanism, different decision, because a fallback is only a downgrade when it replaces something real.

Async actions: pending across a whole operation

One more modern trick: the function inside startTransition can be async. The pending state then spans the entire async operation, isPending stays true until everything inside settles, including awaited network calls.

import { useState, useTransition } from 'react';

function saveName(name) {
  return fetch('/api/name', {
    method: 'POST',
    body: JSON.stringify({ name }),
  });
}

export default function NameForm() {
  const [isPending, startTransition] = useTransition();
  const [name, setName] = useState('');
  const [saved, setSaved] = useState(false);

  function handleSubmit(e) {
    e.preventDefault();
    startTransition(async () => {
      await saveName(name);
      setSaved(true);
    });
  }

  return (
    <form onSubmit={handleSubmit}>
      <input value={name} onChange={(e) => setName(e.target.value)} />
      <button disabled={isPending}>
        {isPending ? 'Saving…' : 'Save'}
      </button>
      {saved && !isPending && <p>Saved!</p>}
    </form>
  );
}

What happens: submit → isPending becomes true for the whole network round trip (button shows "Saving…") → the save resolves, setSaved(true) runs, everything commits together, pending clears. Meanwhile typing in the input stays perfectly responsive, the input update is urgent, the submission is a transition. One flag replaces every hand-rolled loading boolean you've ever written.

Diagram

Rendered diagram (PNG hi-res):

rendered diagram

Rendered diagram (PNG hi-res):

rendered diagram

Common misconceptions

  • Misconception: startTransition makes the slow part faster. Reality: same total work, better ordering, urgent feedback first, heavy content second.
  • Misconception: Transitions are about animation. Reality: the name is about transitioning between screens of content. No animation is involved.
  • Misconception: isPending === true means data is loading. Reality: it means a transition is in flight, the delay might be CPU, data, or both.
  • Misconception: I should wrap every setState in startTransition. Reality: only expensive, non-urgent ones. Wrapping the input's own update would delay the typing feedback, the exact thing you must never defer.
  • Misconception: The old content lingering is a bug. Reality: it's the no-downgrade rule working: brief staleness beats a fallback flash.

Why it works this way

  • You know intent; React knows scheduling. startTransition is the one-word contract between the two.
  • Two renders beat bookkeeping. The urgent render and the transition render are ordinary renders, feedback emerges from priority, not from manual loading flags.
  • No-downgrade matches human perception. A spinner flash replacing real content feels broken; briefly stale content feels like normal loading. Psychology, encoded.
  • Async pending collapses a whole pattern. "Disable the button until the request finishes" becomes one flag that spans the operation.

Try it yourself

  1. Run the no-transition tab demo and click between tabs. Expected: the click feels dead for ~300ms, then everything jumps. Switch to the split-state version. Expected: highlight instant, content follows.
  2. In the split-state version, remove memo from SlowTabPanel. Expected: the highlight is slow again, the urgent render now re-runs the heavy panel with old props. Put memo back. This is why memo and transitions are partners.
  3. Click between tabs rapidly with a console.log inside SlowTabPanel. Expected: fewer heavy panel renders than clicks, intermediate transition drafts get discarded.
  4. In NameForm, type in the input while a slow save is in flight (throttle the network in DevTools). Expected: typing stays smooth; the button shows "Saving…" until the request settles.

Recap

  • startTransition(() => setState(...)) marks updates as non-urgent: time-sliced, preemptible, discardable.
  • Urgent = interaction feedback (must be instant); transition = resulting content (may lag gracefully).
  • The pattern is two renders: a cheap urgent render commits immediately, then a heavy transition render commits when ready.
  • useTransition adds isPending: instant feedback UI with no extra state.
  • Killer rule: if a transition suspends and the fallback would hide visible content, React keeps the old screen instead.
  • Initial load shows fallbacks freely (nothing to lose); transitions refuse downgrades.
  • An async function in startTransition keeps isPending true for the whole operation.

Next

useDeferredValue →