Transitions
The problem: a slow update blocks a fast one
Section titled “The problem: a slow update blocks a fast one”Imagine a search box that filters a huge list. Every keystroke updates two things: the input value (must feel instant) and the filtered results (expensive to compute). If both are urgent, the expensive filter blocks the keystroke, and typing stutters.
Transitions let you split them: the keystroke stays urgent, the filtering becomes a low-priority transition that React can interrupt when the next keystroke arrives.
useTransition: mark an update as non-urgent
Section titled “useTransition: mark an update as non-urgent”useTransition returns [isPending, startTransition]. Wrap the non-urgent state update in startTransition, and use isPending to show a subtle “updating” hint.
function SearchResults({ allItems }) { const [query, setQuery] = useState(""); const [results, setResults] = useState(allItems); const [isPending, startTransition] = useTransition();
function handleChange(e) { setQuery(e.target.value); // urgent — the input updates instantly startTransition(() => { // non-urgent — React renders this in the background, interruptible. setResults(filterExpensive(allItems, e.target.value)); }); }
return ( <> <input value={query} onChange={handleChange} /> {isPending && <span>updating…</span>} <List items={results} /> </> );}The keystroke is never blocked: if you type again before the filter finishes, React throws away the in-progress filter render and starts the new one. isPending is true while the transition renders.
flowchart LR key["keystroke"] --> urgent["setQuery (urgent) input updates now"] key --> trans["startTransition(setResults) (non-urgent)"] trans --> bg["renders in background interruptible by next keystroke"]
useDeferredValue: defer a value instead of an update
Section titled “useDeferredValue: defer a value instead of an update”useDeferredValue is the same idea from the other direction. Instead of wrapping the update, you wrap the value you read: it returns a version of the value that “lags behind” during urgent work, so the expensive part re-renders off the deferred (stale-for-a-moment) value.
function SearchResults({ allItems, query }) { const deferredQuery = useDeferredValue(query); // The list renders against deferredQuery. While the user types, deferredQuery // stays a step behind, so the expensive list render happens in the background. const results = filterExpensive(allItems, deferredQuery);
const isStale = query !== deferredQuery; return <List items={results} style={{ opacity: isStale ? 0.6 : 1 }} />;}Which one?
Section titled “Which one?”- Use
useTransitionwhen you own the state update and can wrap it instartTransition— you have the setter at the call site. - Use
useDeferredValuewhen the value comes from outside your control (a prop, or state you can’t wrap) — you defer reading it instead.
Both keep urgent input responsive by letting the expensive re-render happen in interruptible background work. Neither makes the expensive work faster — they change its priority, not its cost. (For genuinely huge lists, you still want virtualization.)
Async transitions: startTransition(async () => {...})
Section titled “Async transitions: startTransition(async () => {...})”Since React 19, the function you pass to startTransition can be async. React keeps isPending true for the whole callback — including across any await — and only flips it back to false once the callback finishes. This is what makes useTransition work naturally with Server Functions and other async actions: isPending reflects the entire operation, not just its first synchronous tick.
function SaveButton({ formData }) { const [isPending, startTransition] = useTransition();
function handleSave() { startTransition(async () => { await saveToServer(formData); // isPending stays true across this await showToast("Saved!"); }); }
return <button onClick={handleSave} disabled={isPending}>{isPending ? "Saving…" : "Save"}</button>;}The pitfall: a setState after await is not automatically part of the transition
Section titled “The pitfall: a setState after await is not automatically part of the transition”Here’s the part almost every AI-generated snippet gets wrong. React’s own docs are explicit about it:
“Any async calls that are awaited in the action will be included in the Transition, but currently require wrapping any
setfunctions after theawaitin an additionalstartTransition” — react.dev,useTransitiontroubleshooting
In plain terms: everything before the first await runs as part of the transition. A state update issued after an await, if it isn’t re-wrapped in its own startTransition, is treated as a normal, urgent update — not a low-priority one. The clearest way to see the difference is what happens when that update causes a component to suspend. When an update is genuinely part of a transition, React keeps the old content on screen (no Suspense fallback) while the new content loads in the background. When it isn’t part of a transition, React falls back to the nearest Suspense boundary immediately, exactly like any other ordinary update that suspends.
import { useState, useTransition, use, Suspense } from 'react';import { render, screen, act } from '@testing-library/react';
function makeResource() { const cache = new Map<string, Promise<string>>(); return (key: string) => { if (!cache.has(key)) { cache.set(key, new Promise((resolve) => setTimeout(() => resolve(`data:${key}`), 10))); } return cache.get(key)!; };}
function Content({ resource, itemKey }: { resource: (k: string) => Promise<string>; itemKey: string }) { const data = use(resource(itemKey)); return <p data-testid="content">{data}</p>;}
function Demo({ rewrap }: { rewrap: boolean }) { const resource = useState(() => makeResource())[0]; const [itemKey, setItemKey] = useState('a'); const [isPending, startTransition] = useTransition();
function run() { startTransition(async () => { await Promise.resolve().then(() => Promise.resolve()); if (rewrap) { startTransition(() => setItemKey('b')); // ✅ re-wrapped — still part of the transition } else { setItemKey('b'); // ❌ NOT part of the transition — the classic bug } }); }
return ( <div> <button onClick={run}>run</button> <p data-testid="pending">{String(isPending)}</p> <Suspense fallback={<p data-testid="fallback">loading…</p>}> <Content resource={resource} itemKey={itemKey} /> </Suspense> </div> );}
test('re-wrapped: switching to a suspending key stays part of the transition — no fallback, old content stays', async () => { await act(async () => { render(<Demo rewrap={true} />); }); await act(async () => { await new Promise((r) => setTimeout(r, 20)); }); expect(screen.getByTestId('content').textContent).toBe('data:a');
await act(async () => { screen.getByText('run').click(); await Promise.resolve(); await Promise.resolve(); }); expect(screen.queryByTestId('fallback')).toBeNull(); // no fallback — still part of the transition expect(screen.getByTestId('content').textContent).toBe('data:a'); // old content held while the new one loads expect(screen.getByTestId('pending').textContent).toBe('true');});
test('unwrapped: a setState after a bare await is NOT part of the transition — it falls back immediately', async () => { await act(async () => { render(<Demo rewrap={false} />); }); await act(async () => { await new Promise((r) => setTimeout(r, 20)); }); expect(screen.getByTestId('content').textContent).toBe('data:a');
await act(async () => { screen.getByText('run').click(); await Promise.resolve(); await Promise.resolve(); }); // Because setItemKey('b') after the bare await is a normal, urgent update, // React does NOT hold the old content while it loads — the boundary falls // back immediately, exactly react.dev's documented pitfall. expect(screen.getByTestId('fallback')).toBeTruthy();});Output from this exact test file, run against [email protected] under the course harness:
✓ re-wrapped: switching to a suspending key stays part of the transition — no fallback, old content stays✓ unwrapped: a setState after a bare await is NOT part of the transition — it falls back immediatelyErrors thrown inside a transition reach the nearest error boundary
Section titled “Errors thrown inside a transition reach the nearest error boundary”A startTransition update is a normal render — if it throws (synchronously, or via a set call issued while still inside the transition), React propagates that error to the nearest error boundary above it, exactly as it would for any other render. isPending/the transition machinery isn’t a substitute for error handling.
import { Component, useState, useTransition, type ReactNode } from 'react';import { render, screen, act } from '@testing-library/react';
class Boundary extends Component<{ children: ReactNode }, { error: Error | null }> { state = { error: null as Error | null }; static getDerivedStateFromError(error: Error) { return { error }; } render() { if (this.state.error) return <p data-testid="boundary-fallback">caught: {this.state.error.message}</p>; return this.props.children; }}
function Boom({ shouldThrow }: { shouldThrow: boolean }) { if (shouldThrow) throw new Error('transition-boom'); return <p data-testid="ok">ok</p>;}
function Demo() { const [shouldThrow, setShouldThrow] = useState(false); const [, startTransition] = useTransition(); function trigger() { startTransition(async () => { await Promise.resolve(); startTransition(() => setShouldThrow(true)); // throws inside the transition's render }); } return ( <Boundary> <button onClick={trigger}>trigger</button> <Boom shouldThrow={shouldThrow} /> </Boundary> );}
test('a throw caused by a transition update reaches the nearest error boundary', async () => { await act(async () => { render(<Demo />); }); expect(screen.getByTestId('ok')).toBeTruthy();
await act(async () => { screen.getByText('trigger').click(); await Promise.resolve(); await Promise.resolve(); }); expect(screen.getByTestId('boundary-fallback').textContent).toBe('caught: transition-boom');});✓ a throw caused by a transition update reaches the nearest error boundarystartTransition scope and batching
Section titled “startTransition scope and batching”startTransition’s callback can update as many pieces of state as you like — all of them share the same “non-urgent” priority and are batched into one transition render. There’s nothing special about calling it once per update; group everything that should defer together inside a single startTransition call so React treats them as one unit of interruptible work.
Mental Model
Section titled “Mental Model”Think of two lanes into the renderer: an express lane (urgent — typing, clicking, anything that must feel instant) and a freight lane (transitions — anything you’ve explicitly marked non-urgent). Both lanes can carry state updates, but the express lane always merges in first; freight gets paused and re-queued whenever a new express update shows up. An await inside an async transition is a tollbooth: whatever loads onto the freight truck before the tollbooth stays freight, but anything loaded after it needs a new “freight” sticker (startTransition) or it defaults back to the express lane.
Trade-offs
Section titled “Trade-offs”| What you get | Benefit | Cost |
|---|---|---|
useTransition | You control exactly which update is deferred, plus isPending for UI feedback | Requires owning the setter at the call site |
useDeferredValue | Works when you only have a value (a prop), no setter needed | No isPending-style boolean — compare value !== deferredValue yourself |
Async startTransition | isPending spans the whole async operation, including Server Functions | Every set call after an await needs its own startTransition, or it silently reverts to urgent priority |
| Not using transitions at all | Simpler code, no priority concepts | A single expensive update can block every keystroke until it finishes |
Common Misconceptions
Section titled “Common Misconceptions”- “Wrapping the whole async function in
startTransitioncovers every update inside it.” Only the code that runs before the firstawaitis covered. Anything after anawaitneeds its ownstartTransitioncall — proven above. - Transitions make the computation itself faster. They don’t — they change when and how interruptibly the update is applied, not its cost. A genuinely huge list still needs virtualization or a cheaper algorithm.
isPendingalone tells you an update is safely deferred.isPendingreflects the whole async callback’s lifetime, but any individualsetcall inside it can still be “urgent” if it wasn’t (re-)wrapped after anawait—isPendingbeingtruedoesn’t guarantee every update in that callback is low priority.
Reading AI-generated code
Section titled “Reading AI-generated code”When you’re reviewing a generated snippet that uses startTransition with an async callback, check for:
- Does every
setcall after anawaitsit inside its ownstartTransition(() => ...)? If not, that update silently reverted to urgent priority — a very common, hard-to-spot AI-generated bug. - Is
isPendingused to disable a button or show a spinner, but the actual mutating call sits outsidestartTransitionentirely? ThenisPendingnever reflects that work. - If the transition can suspend (e.g. it flips a key that a
use()-reading child depends on), does the code assume “old content stays visible”? That assumption only holds for updates that are genuinely part of the transition. - Does a
throwinside the transition’s callback have a nearby error boundary, or does the generated code assumetry/catcharoundstartTransitionitself would catch it? (It won’t — the render happens later, outside that synchronous call.)
🐛 Spot the bug
function SearchPage() {const [query, setQuery] = useState("");const [isPending, startTransition] = useTransition();
function handleChange(e) { const value = e.target.value; setQuery(value); startTransition(async () => { const results = await searchAPI(value); setSearchResults(results); // set AFTER the await, not re-wrapped });}
return ( <> <input value={query} onChange={handleChange} /> {isPending && <span>Searching…</span>} <ResultsList /> </>);}Show the answer
setSearchResults(results) runs after await searchAPI(value) and is not wrapped in its own startTransition. Per react.dev’s useTransition troubleshooting section, only the code before the first await is treated as part of the transition — this call is a normal, urgent update. If ResultsList reads a promise via use() and this update causes it to suspend, React will not keep the old results on screen the way the isPending UI implies; it falls back to the nearest Suspense boundary immediately. The fix is startTransition(() => setSearchResults(results)) around the post-await call.