Command Palette

Search for a command to run...

Hectal
PHASE 19Advanced React 19+ ~23 min· topic 2 of 8

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 async that returns a promise and can await other promises.
isPending
The flag from useTransition that 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 await still 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.

SaveButton.jsxwhole filejsx
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.

SaveButton.jsxwhole filejsx
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.

Why the second startTransitiondiagram
Rendering diagram…

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.

SaveButton.jsxwhole filejsx
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. 1

    Type while it saves

    In the first example, make fakeSave wait 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. 2

    Catch the error inline

    In the second example, add const [error, setError] = useState(null) to SaveButton, wrap the await in try/catch and call startTransition(() => setError(e.message)). Show {error && <p>{error}</p>}. Predict: does the boundary still show its fallback?

  3. 3

    Ignore stale answers

    In the ordering example, add const latest = useRef(0). In search, do const id = ++latest.current before the await, and after it only set the result if id === latest.current. Predict the final text, then run.

Code & diagrams

An async Action with a pending state React 19+ New tab

`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

Press Run to see the page.

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=ASHA
A failing Action reaches the error boundary React 19+ New tab

The 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

Press Run to see the page.

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 no
Ordering: a slow old answer overwrites a fast new one React 19+ New tab

The 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

Press Run to see the page.

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=false

Break 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);.

terminal
$ Console
── what you'll see ──
render pending=false saved=
action start
render pending=true saved=
got ASHA
render pending=true saved=ASHA
render pending=false saved=ASHA

Break #2

Throw from an Action with no boundary

In the second example render <SaveButton /> without <Boundary> around it.

terminal
$ Console
── what you'll see ──
Uncaught Error: Server said no
An error occurred in the <SaveButton> component.
 
Consider adding an error boundary to your tree to customize error handling behavior.
Visit https://react.dev/link/error-boundaries to learn more about error boundaries.

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 results render.

  • ▸

    Updates made inside an Action, even before the first await, are transition updates: they don't show until the whole Action finishes. A setError("") at the start of an Action leaves the old error on screen while the request runs. Use isPending, useFormStatus or useOptimistic for anything that must show immediately.

  • ▸

    The extra startTransition after await is a known limitation documented by React; it will go away if the TC39 AsyncContext proposal lands and React adopts it.

  • ▸

    The module-level startTransition from react accepts 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 isPending true. That's usually what you want for a Save button but can surprise you on navigation.

Remember this

  1. 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, and isPending is true meanwhile. In React 18 the function passed to startTransition had to be synchronous: anything after an await escaped the transition.

  2. 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, isPending stays true, so one flag covers the whole request: you no longer need a separate const [saving, setSaving] = useState(false) with setSaving(true) before and setSaving(false) in finally.

  3. 3

    **State updates after an await need their own startTransition.** JavaScript loses track of the surrounding transition once an await resumes (React can't see across the gap until the JavaScript AsyncContext proposal lands). So write const data = await load(); startTransition(() => setData(data));. If you forget, the update is treated as urgent and commits on its own, before isPending turns 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. 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 isPending goes back to false. To show an inline message instead, try/catch inside the Action and store the message in state. Never leave errors uncaught on purpose: with no boundary, React unmounts the whole root.

  5. 5

    Ordering. Several Actions can run at once (two clicks, two searches). React keeps isPending true until all of them finish and then commits their updates together, but the order your awaits resolve in is up to the network. If a slow old request finishes after a fast new one, its setResult runs last and the stale result wins. Fixes: let useActionState queue 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 with AbortController.

  6. 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 startTransition imported from react also accepts async functions; it just gives you no isPending.

Explain it without notes

01

What is an Action in React 19 and how does it differ from React 18 transitions?

02

Why must state updates after await be wrapped in startTransition again?

03

What happens when an Action throws?

04

Two searches run as Actions and the older one shows. Why, and how do you fix it?

Practice

01

Write a DeleteButton that calls fakeDelete() (300 ms) as an Action, shows Deleting... and is disabled while pending.

02

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 startTransition and show isPending.

  • I wrap state updates after await in startTransition.

  • 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.