Topic 19.2
Actions: Async Transitions
In one line
In React 19 startTransition accepts an async function: React calls it an Action, keeps isPending true until it finishes, sends errors it throws to the nearest error boundary, and lets the rest of the UI stay responsive while it waits.
Think of it like this
Ordering at a café counter with a buzzer. You order, the cashier hands you a buzzer, and you go and sit down. You are not frozen at the counter: you can chat and read. The buzzer's light tells everyone your order is in progress; when it buzzes the coffee arrives. If the kitchen runs out of milk, someone comes to tell you (the error doesn't vanish). An Action is the order, isPending is the buzzer light, and the error boundary is the person who comes over with bad news.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Action
- A function run inside a transition. In React 19 it may be async; React tracks it until it finishes.
- Async function
- A function marked
asyncthat returns a promise and canawaitother promises. - isPending
- The flag from
useTransitionthat is true while any of its transitions (or Actions) is still running. - Urgent update
- A normal state update that React applies right away, like typing into an input.
- Error boundary
- A component that catches errors from the components inside it and shows a fallback instead (Topic 14.5).
- Race condition
- A bug where the result depends on which of two things finishes first, like two requests answering out of order.
- AsyncContext
- A proposed JavaScript feature that would let React see that code after an
awaitstill belongs to the same transition.
Step by step
01The React 18 way: a hand-made pending flag
Before React 19, every async button followed the same recipe: a saving state, set it to true, await, set the result, set it to false in finally, plus error state. Easy to forget a step, and repeated in every component.
const [saving, setSaving] = useState(false);
const [error, setError] = useState(null);
async function save() {
setSaving(true);
setError(null);
try {
await api.save(draft);
} catch (e) {
setError(e.message);
} finally {
setSaving(false);
}
}02The React 19 way: an async transition
Pass the async function to startTransition. isPending turns true as soon as the Action starts and false when its promise settles. The button can be disabled with it, and the rest of the page stays responsive.
const [isPending, startTransition] = useTransition();
function save() {
startTransition(async () => {
const saved = await api.save(draft);
startTransition(() => setSaved(saved));
});
}03Why the second startTransition
The await splits the function in two. The first half runs inside the transition React started. The second half runs later, from the promise's callback, and React can no longer tell it belongs to the same transition. Wrapping the update again puts it back inside, so it commits together with isPending becoming false.
04Errors go to the nearest boundary
If the awaited call rejects and you don't catch it, React treats the error like one thrown during render of the component that started the transition. The nearest error boundary shows its fallback. Catch it inside the Action when you prefer a message next to the button.
startTransition(async () => {
try {
await api.save(draft);
} catch (e) {
startTransition(() => setError(e.message)); // inline message
}
});05Many Actions at once share one pending flag
Each click starts another Action. isPending stays true until the last one is done, then React commits all their updates in one render. Whatever setState ran last wins, and that is the order the answers came back in, not the order of the clicks.
Try it yourself
- 1
Type while it saves
In the first example, make
fakeSavewait 1500 ms. Click Save and type in the input at once. Predict: does the input freeze? It doesn't: the Action is a transition, typing is urgent. - 2
Catch the error inline
In the second example, add
const [error, setError] = useState(null)toSaveButton, wrap theawaitintry/catchand callstartTransition(() => setError(e.message)). Show{error && <p>{error}</p>}. Predict: does the boundary still show its fallback? - 3
Ignore stale answers
In the ordering example, add
const latest = useRef(0). Insearch, doconst id = ++latest.currentbefore theawait, and after it only set the result ifid === latest.current. Predict the final text, then run.
Code & diagrams
`isPending` turns true as the Action starts and false only after the result is set. The extra `pending=true` render after `got ASHA` is React rendering the new update before committing it with the end of the Action. Click Save yourself and type while it saves: the input never freezes.
Preview
Console
Nothing logged yet.Expected console output
render pending=false saved=
action start
render pending=true saved=
got ASHA
render pending=true saved=
render pending=false saved=ASHAThe rejected promise is thrown from `SaveButton`, so `Boundary` shows its fallback. React logs the caught error once with `console.error` (that's the first lines), then the page text is logged.
Preview
Console
Nothing logged yet.Expected console output
Error: Server said no
The above error occurred in the <SaveButton> component.
React will try to recreate this component tree from scratch using the error boundary you provided, Boundary.
Caught: Server said noThe user's latest search was `ab`, but the page ends on `A results`: the slow request answered last, so its update ran last. `isPending` stayed true until both finished. Topic 19.4 fixes this with a queue; a request id in a ref also works.
Preview
Console
Nothing logged yet.Expected console output
render result=none pending=false
search a sent
render result=none pending=true
search ab sent
render result=none pending=true
search ab answered
render result=none pending=true
search a answered
render result=none pending=true
render result=A results pending=falseBreak it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Forget the startTransition after await
In the first example replace startTransition(() => setSaved(result)); with setSaved(result);.
Break #2
Throw from an Action with no boundary
In the second example render <SaveButton /> without <Boundary> around it.
Myth vs fact
Myth
An async Action blocks the UI until the request finishes.
Fact
It is a transition: typing, clicking and other urgent updates go on, and only isPending tells you it's running.
Myth
React runs my Actions in the order I started them.
Fact
startTransition Actions run at the same time and their updates land in the order their promises resolve. Only useActionState (and form actions using it) queues calls one after another.
Myth
isPending belongs to one click.
Fact
It belongs to the useTransition call: it stays true while any Action started from that hook is still running.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
React 19 tracks async Actions by entangling them: all transitions running at the same time are committed together when the last one settles. That's why the ordering demo shows no intermediate
AB resultsrender. - ▸
Updates made inside an Action, even before the first
await, are transition updates: they don't show until the whole Action finishes. AsetError("")at the start of an Action leaves the old error on screen while the request runs. UseisPending,useFormStatusoruseOptimisticfor anything that must show immediately. - ▸
The extra
startTransitionafterawaitis a known limitation documented by React; it will go away if the TC39 AsyncContext proposal lands and React adopts it. - ▸
The module-level
startTransitionfromreactaccepts async functions too, but returns no pending flag. Libraries use it to mark their own updates as transitions without needing a hook. - ▸
Because an Action is a transition, a component that suspends during it won't show a nearby Suspense fallback; the previous UI stays on screen with
isPendingtrue. That's usually what you want for a Save button but can surprise you on navigation.
Remember this
- 1
Recall transitions (Topic 16.7):
const [isPending, startTransition] = useTransition()marks state updates as non-urgent, so React can keep the screen responsive (typing, clicking) while it renders them, andisPendingistruemeanwhile. In React 18 the function passed tostartTransitionhad to be synchronous: anything after anawaitescaped the transition. - 2
React 19 lets you pass an async function:
startTransition(async () => { await save(); }). React calls such a function an Action. While its promise is unsettled,isPendingstaystrue, so one flag covers the whole request: you no longer need a separateconst [saving, setSaving] = useState(false)withsetSaving(true)before andsetSaving(false)infinally. - 3
**State updates after an
awaitneed their ownstartTransition.** JavaScript loses track of the surrounding transition once anawaitresumes (React can't see across the gap until the JavaScript AsyncContext proposal lands). So writeconst data = await load(); startTransition(() => setData(data));. If you forget, the update is treated as urgent and commits on its own, beforeisPendingturns false: the screen briefly shows the new data with the old "Saving..." label still on.useActionState(Topic 19.4) and form actions handle this for you. - 4
Errors. If an Action throws (or its promise rejects), React re-throws the error during render of the component that started it, so the nearest error boundary (Topic 14.5) shows its fallback, and
isPendinggoes back tofalse. To show an inline message instead,try/catchinside the Action and store the message in state. Never leave errors uncaught on purpose: with no boundary, React unmounts the whole root. - 5
Ordering. Several Actions can run at once (two clicks, two searches). React keeps
isPendingtrue until all of them finish and then commits their updates together, but the order yourawaits resolve in is up to the network. If a slow old request finishes after a fast new one, itssetResultruns last and the stale result wins. Fixes: letuseActionStatequeue the calls (it runs them one after another), ignore answers that are not the latest (a request id in a ref, like the ignore flag in Topic 7.7), or cancel withAbortController. - 6
Under the hood an Action is still a transition, so everything from Topic 16.7 applies: urgent updates (typing) interrupt it, and a component that suspends inside it keeps the old UI on screen instead of showing a Suspense fallback (Topic 14.3). The standalone
startTransitionimported fromreactalso accepts async functions; it just gives you noisPending.
Explain it without notes
What is an Action in React 19 and how does it differ from React 18 transitions?
Why must state updates after await be wrapped in startTransition again?
What happens when an Action throws?
Two searches run as Actions and the older one shows. Why, and how do you fix it?
Practice
Write a DeleteButton that calls fakeDelete() (300 ms) as an Action, shows Deleting... and is disabled while pending.
Change the ordering example so only the latest search can set the result.
Trade-offs
- ↔
Actions remove boilerplate and keep the UI responsive, but you give up fine control over each step's commit: everything waits for the whole Action. For progress bars or partial results, set state in urgent updates on purpose.
- ↔
Running Actions in parallel is fast but can apply stale results; queueing (
useActionState) is always ordered but slower when the user fires many requests.
Done when you can
I can run an async function with
startTransitionand showisPending.I wrap state updates after
awaitinstartTransition.I can send Action errors to an error boundary or show them inline.
I can explain why parallel Actions can apply results out of order and fix it.
I know an Action is still a transition and what that means for Suspense.