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
useTransitionandisPending: 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:
- You click posts.
setTab('posts')schedules an urgent update, everything in a click handler is urgent by default. - React starts an urgent render. But that render includes
SlowTabPanelwith the new tab: 300ms of synchronous work. - The urgent render cannot be interrupted, so the main thread is blocked for 300ms.
- The button's bold highlight is part of that same render, so it can't paint either. Your click looks ignored.
- 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:
- You click posts. Two updates are scheduled:
tab = 'posts'(discrete/urgent) andpanel = 'posts'(transition lane). - Render 1, urgent.
tabis new,panelis still'home'. This render is cheap:SlowTabPanelis wrapped inmemo, 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. - 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. - 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.
- 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
useTransitionthat istruefrom 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:
- Click posts.
setTabgoes to the transition lane, andisPendingflips totrueas an urgent update. - Render 1, urgent:
tabis still'home'(the transition update is excluded from this render), butisPendingistrue. Tiny diff: the "Loading…" badge appears and the old panel dims. Commits in one frame, your click is acknowledged instantly. - Render 2, transition:
tab = 'posts',isPending = false, plus the heavy panel. Sliced, preemptible, discardable. - 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 (PNG hi-res):
Common misconceptions
- Misconception:
startTransitionmakes 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 === truemeans data is loading. Reality: it means a transition is in flight, the delay might be CPU, data, or both. - Misconception: I should wrap every
setStateinstartTransition. 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.
startTransitionis 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
- 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.
- In the split-state version, remove
memofromSlowTabPanel. Expected: the highlight is slow again, the urgent render now re-runs the heavy panel with old props. Putmemoback. This is why memo and transitions are partners. - Click between tabs rapidly with a
console.loginsideSlowTabPanel. Expected: fewer heavy panel renders than clicks, intermediate transition drafts get discarded. - 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.
useTransitionaddsisPending: 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
asyncfunction instartTransitionkeepsisPendingtrue for the whole operation.