The finance lead opened the revenue report on a Monday and clicked Quarter, then Year, to compare the two. Each click emptied the page. The figures and the overdue list she had been reading vanished behind “Loading the report…”, the page shrank under her scroll position, and a second later everything came back in a slightly different place.
She asked whether the report reloaded the whole page on every click. It didn’t. It did something that looked the same: it threw away what was on screen because the next screen wasn’t ready yet.
A Transition marks an update as one that can wait. React prepares it in the background, keeps the current screen in place meanwhile, and reports that something is on its way. useTransition gives you both halves, startTransition and isPending, and since React 19 the function you pass it may be async: an Action, pending until its request settles.
Every click, a blank report
The report reads its figures from a promise with use, inside a Suspense boundary. A click on a period starts a request and puts the new promise in state:
const [shown, setShown] = useState(initial);
// Avoid: an urgent update that suspends shows the fallback again
function choose(period: Period) {
setShown({ period, report: loadReport(period) });
}A click is an urgent update, and React commits urgent updates as soon as it can. The new promise is pending, so the figures suspend, and the only thing React has to show for them is the boundary’s fallback. The content already on screen goes, because the state now says it is out of date.
Keep the old screen until the new one is ready
One click asks for two things. The pressed button must change now, because it is how people know the click landed. The figures can arrive a moment later, as long as the screen says so. A Transition is the second kind of update: React renders it out of view, may abandon it for a newer one, and swaps it in when it is complete.
Urgent updates outside, everything that can wait inside. useTransition returns the function that marks the inside, startTransition, and a flag, isPending, that is true from the first call until the screen shows the result.
Splitting the click in two
The period and the report become two pieces of state, and the report changes in a Transition. Then the reminder button gets an Action of its own.
Keep the button urgent, the figures in a Transition
// The buttons: urgent, so a click shows at once.
const [period, setPeriod] = useState(initial.period);
// The figures: they can wait, so they change in a transition.
const [report, setReport] = useState(initial.report);
const [isPending, startTransition] = useTransition();
function choose(next: Period) {
if (next === period) return;
setPeriod(next);
startTransition(() => setReport(loadReport(next)));
}setPeriod runs as usual, so the pressed button moves at the click. setReport runs inside startTransition, so when the new promise suspends, React keeps the figures that were already revealed instead of falling back. Click Quarter and then Year before either answers, and the second Transition replaces the first: only the year’s figures will render.
Say that the figures are on their way
While isPending is true, the figures on screen belong to the old period. The page dims them and marks their region aria-busy, so the change is plain without hiding rows someone may still be reading. Keep the pending look light: it renders on every switch.
An Action for the reminders
The overdue list has one action, sending every customer on it a reminder. It needs its own pending state, apart from the period’s, so its hook, useReminders, calls useTransition itself:
// The overdue list's one action: send every customer on it a
// reminder.
import { useState, useTransition } from "react";
import type { OverdueInvoice, SendReminders } from "./report";
interface Notice {
/** New for every message, so a repeated sentence is a new line. */
id: number;
text: string;
}
interface RemindOverdueProps {
overdue: OverdueInvoice[];
sendReminders: SendReminders;
}
/** The reminder Action, its pending state and its last message. */
export function useReminders(
overdue: OverdueInvoice[],
sendReminders: SendReminders,
) {
const [isPending, startTransition] = useTransition();
const [notice, setNotice] = useState<Notice | null>(null);
const say = (text: string) =>
setNotice((last) => ({ id: (last?.id ?? 0) + 1, text }));
function remind() {
if (isPending) return;
// An Action: isPending stays true until the request settles.
startTransition(async () => {
try {
const sent = await sendReminders(
overdue.map((invoice) => invoice.id),
);
// After an await, an update needs its own transition.
startTransition(() =>
say(`Reminders sent for ${sent} overdue invoices.`),
);
} catch {
startTransition(() =>
say("The reminders didn’t send. Try again."),
);
}
});
}
return { isPending, notice, remind };
}
export function RemindOverdue({
overdue,
sendReminders,
}: RemindOverdueProps) {
const { isPending, notice, remind } = useReminders(
overdue,
sendReminders,
);
return (
<>
<h3>Overdue</h3>
<ul>
{overdue.map((invoice) => (
<li key={invoice.id}>
{invoice.customer}, {invoice.total}
</li>
))}
</ul>
{/* aria-disabled, not disabled: it keeps keyboard focus. */}
<button
type="button"
aria-disabled={isPending || undefined}
onClick={remind}
>
{isPending ? "Sending reminders…" : "Send reminders"}
</button>
<p role="status">
{notice ? <span key={notice.id}>{notice.text}</span> : null}
</p>
</>
);
}The async function passed to startTransition is the Action. isPending turns true at the click and stays true across the await, so there is no flag to set before the request and clear after it. Pressing the button again while it is pending does nothing, and the button keeps focus because it is aria-disabled rather than disabled.
Pitfall
An update after an await is not part of the Transition. React loses the async context at the await, so setNotice there would run as an urgent update. Wrap each update after an await in its own startTransition, as react.dev’s reference for useTransition explains; the limitation goes once JavaScript has async context.
The finished report
// The revenue report: the period buttons answer at once, and the
// figures for the new period render in a transition, with the old
// ones in view.
import { Suspense, use, useState, useTransition } from "react";
import { RemindOverdue } from "./remind-overdue";
import { PERIODS } from "./report";
import type {
LoadReport,
Period,
Report,
SendReminders,
} from "./report";
function Figures({
report,
sendReminders,
}: {
report: Promise<Report>;
sendReminders: SendReminders;
}) {
const { revenue, invoices, label, overdue } = use(report);
return (
<>
<p>
{revenue} from {invoices} invoices {label}
</p>
<RemindOverdue
overdue={overdue}
sendReminders={sendReminders}
/>
</>
);
}
interface RevenueReportProps {
initial: { period: Period; report: Promise<Report> };
loadReport: LoadReport;
sendReminders: SendReminders;
}
export function RevenueReport({
initial,
loadReport,
sendReminders,
}: RevenueReportProps) {
// The buttons: urgent, so a click shows at once.
const [period, setPeriod] = useState(initial.period);
// The figures: they can wait, so they change in a transition.
const [report, setReport] = useState(initial.report);
const [isPending, startTransition] = useTransition();
function choose(next: Period) {
if (next === period) return;
setPeriod(next);
startTransition(() => setReport(loadReport(next)));
}
return (
<section aria-labelledby="report-heading">
<h2 id="report-heading">Revenue</h2>
<div role="group" aria-label="Period">
{PERIODS.map((option) => (
<button
key={option.value}
type="button"
aria-pressed={option.value === period}
onClick={() => choose(option.value)}
>
{option.label}
</button>
))}
</div>
{/* The old figures stay in view, dimmed and marked busy. */}
<section
aria-label="Figures"
aria-busy={isPending}
style={{ opacity: isPending ? 0.6 : 1 }}
>
<Suspense fallback={<p>Loading the report…</p>}>
<Figures report={report} sendReminders={sendReminders} />
</Suspense>
</section>
</section>
);
}Click Quarter and the button shows pressed at once, while this month’s figures stay in view, dimmed, until the quarter’s arrive. Send reminders and the button reads “Sending reminders…” until the server answers; then the status line says how many went out, and says it again, as a new line, if you send them twice.
What a Transition promises, and what it doesn’t
A Transition changes when work becomes visible and what can interrupt it. It doesn’t make the work smaller, and it doesn’t order your requests.
Interruptible, not faster
A Transition’s render takes as long as the same render outside one. What changes is that an urgent update, a click or a keystroke, can interrupt it, and React starts the Transition again from the newer state. When the render itself is the slow part, a Transition keeps the page responsive while it runs; to make the render shorter, render less.
Order across awaits
Answers that arrive after an await come back in whatever order the network chooses. Two Actions that overlap can settle either way round, and React currently batches Transitions that overlap, so both show their result together. The report sidesteps both: the period’s figures come from the promise in state, never from whichever answer lands last. When each Action needs the result of the one before it, useActionState runs them in sequence.
When an Action fails
An error thrown inside startTransition, or a promise from it that rejects, goes to the nearest error boundary, the way a render error does. That suits a failure that breaks the section. A reminder that didn’t send isn’t one: the catch turns it into a sentence beside the button, and the report stays usable.
Pending, in words
Each moment has a word as well as a look. The pressed button carries aria-pressed from the click. The figures’ region is aria-busy while it is out of date. The reminder button’s label changes while it works, and its result goes to a status line that is on the page, empty, before anything is said in it. Each message is a new keyed line, so sending twice is heard twice.
Testing the moment in between
The test answers each request by hand, so it can look at the page while a Transition is pending:
import { act, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { expect, test, vi } from "vitest";
import { RevenueReport } from "./revenue-report";
import * as before from "./revenue-report-before";
import type { Period, Report } from "./report";
const MONTH: Report = {
label: "this month",
revenue: "£6,120.00",
invoices: 14,
overdue: [
{ id: "inv_1", customer: "Northwind Ltd", total: "£480.00" },
{ id: "inv_2", customer: "Bluebell Bakery", total: "£96.00" },
],
};
const QUARTER: Report = {
...MONTH,
label: "this quarter",
revenue: "£18,240.00",
invoices: 42,
};
/** A report server the test answers when it chooses. */
function heldReports() {
const answers = new Map<Period, (report: Report) => void>();
const loadReport = vi.fn(
(period: Period) =>
new Promise<Report>((resolve) => answers.set(period, resolve)),
);
const answer = (period: Period, report: Report) =>
act(async () => answers.get(period)?.(report));
return { loadReport, answer };
}
async function renderMonth(
sendReminders = vi.fn(() => Promise.resolve(2)),
) {
const reports = heldReports();
const initial = {
period: "month" as const,
report: reports.loadReport("month"),
};
// The report suspends as it mounts, so it renders inside an awaited
// act(): a synchronous render would leave React's retry queued and
// never run.
await act(async () => {
render(
<RevenueReport
initial={initial}
loadReport={reports.loadReport}
sendReminders={sendReminders}
/>,
);
});
await reports.answer("month", MONTH);
// React reveals a boundary's content in batches, so wait for the
// figures.
await screen.findByText("£6,120.00 from 14 invoices this month");
return reports;
}
test("the first version blanks the figures on a switch", async () => {
const reports = heldReports();
const initial = {
period: "month" as const,
report: reports.loadReport("month"),
};
await act(async () => {
render(
<before.RevenueReport
initial={initial}
loadReport={reports.loadReport}
/>,
);
});
await reports.answer("month", MONTH);
await screen.findByText("£6,120.00 from 14 invoices this month");
// A click that suspends runs in an awaited act(), so React's retry
// runs.
await act(() =>
userEvent.click(screen.getByRole("button", { name: "Quarter" })),
);
// React hides the revealed figures and shows the fallback in their
// place.
expect(
screen.getByText("£6,120.00 from 14 invoices this month"),
).not.toBeVisible();
expect(screen.getByText("Loading the report…")).toBeVisible();
});
test("a switch keeps old figures until new ones arrive", async () => {
const reports = await renderMonth();
await act(() =>
userEvent.click(screen.getByRole("button", { name: "Quarter" })),
);
expect(
screen.getByRole("button", { name: "Quarter" }),
).toHaveAttribute("aria-pressed", "true");
const figures = screen.getByRole("region", { name: "Figures" });
expect(figures).toHaveAttribute("aria-busy", "true");
expect(
within(figures).getByText(
"£6,120.00 from 14 invoices this month",
),
).toBeVisible();
expect(screen.queryByText("Loading the report…")).toBeNull();
await reports.answer("quarter", QUARTER);
expect(
await within(figures).findByText(
"£18,240.00 from 42 invoices this quarter",
),
).toBeVisible();
expect(figures).toHaveAttribute("aria-busy", "false");
});
test("the Action is pending; each send is a new line", async () => {
let settle = (_sent: number) => {};
const sendReminders = vi.fn(
() => new Promise<number>((resolve) => (settle = resolve)),
);
await renderMonth(sendReminders);
const user = userEvent.setup();
await user.click(
screen.getByRole("button", { name: "Send reminders" }),
);
const pending = screen.getByRole("button", {
name: "Sending reminders…",
});
expect(pending).toHaveAttribute("aria-disabled", "true");
await user.click(pending);
expect(sendReminders).toHaveBeenCalledTimes(1);
expect(sendReminders).toHaveBeenCalledWith(["inv_1", "inv_2"]);
await act(async () => settle(2));
const status = screen.getByRole("status");
const first = within(status).getByText(
"Reminders sent for 2 overdue invoices.",
);
await user.click(
screen.getByRole("button", { name: "Send reminders" }),
);
await act(async () => settle(2));
const second = within(status).getByText(
"Reminders sent for 2 overdue invoices.",
);
expect(second).not.toBe(first);
});All three pass. The first records the problem: the first version shows the fallback the moment Quarter is clicked. The second shows the fix, with the old figures in view and the region busy until the new report arrives.
Server rendering and Server Functions
On the server there is nothing to wait between: the page renders with the period its address names, and the route, or a Server Component, starts the first report and passes its promise in. In the browser, an Action can call a Server Function like any other async function, so sendReminders could run on the server with no change to the button.
React 18, 19 and the Compiler
React 18 introduced useTransition for synchronous updates; React 19 lets the function be async and keeps isPending true until it settles, which is what makes it an Action. In React 18, the reminder button would need its own pending flag. The React Compiler leaves all of this as it is: it memoises renders, and a Transition is about when they show.
When an update should stay urgent
Leave out of Transitions what people must see change at once: the pressed button, a checkbox, a field’s text. A Transition can’t drive a text input at all, because typing has to show synchronously. When the slow part follows a field, defer the value it reads with useDeferredValue.
And when an update is fast, it doesn’t need one. A Transition earns its place when an update suspends on data or takes long enough to render that someone could click again before it finishes.
How Turtlekit’s SegmentedControl and Button carry the two states
With Turtlekit, the period buttons become a SegmentedControl and the reminder button a Button whose status follows the Action:
// The revenue report's controls with Turtlekit: SegmentedControl for
// the period, and a Button whose status follows the reminder Action.
import { Button, SegmentedControl, Text } from "turtlekit";
import { useReminders } from "./remind-overdue";
import { PERIODS } from "./report";
import type { OverdueInvoice, Period, SendReminders } from "./report";
interface RemindButtonProps {
overdue: OverdueInvoice[];
sendReminders: SendReminders;
}
interface PeriodPickerProps {
period: Period;
/** Sets the period at once, and the figures in a transition. */
onChoose: (period: Period) => void;
}
export function PeriodPicker({
period,
onChoose,
}: PeriodPickerProps) {
return (
<SegmentedControl
label="Period"
options={PERIODS}
value={period}
onValueChange={(value) => onChoose(value as Period)}
/>
);
}
/** The same Action: a failure caught, the result in a status line. */
export function RemindButton({
overdue,
sendReminders,
}: RemindButtonProps) {
const { isPending, notice, remind } = useReminders(
overdue,
sendReminders,
);
return (
<>
{/* Pending, the Button ignores presses but keeps focus. */}
<Button
variant="soft"
status={isPending ? "pending" : "idle"}
pendingLabel="Sending reminders…"
onClick={remind}
>
Send reminders
</Button>
<Text as="p" role="status">
{notice ? <span key={notice.id}>{notice.text}</span> : null}
</Text>
</>
);
}The control’s value is the urgent period, so it moves at the click while the figures wait, as before. RemindButton keeps useReminders as it was, the catch and the keyed status line included, and only the button changes: given status="pending", the kit’s Button shows a spinner beside its pending label, ignores presses and keeps focus.
When a Transition doesn’t hold the screen
The figures still blank on a switch
Either the update isn’t inside startTransition, or it suspends inside a Suspense boundary that wasn’t on screen yet. A Transition waits only to avoid hiding content already revealed; a nested boundary inside the new content shows its own fallback.
isPending turns false before the request ends
The function passed to startTransition didn’t wait for the request. Make it async and await the request inside it, or return its promise. An update scheduled in a setTimeout isn’t part of the Transition either.
An update after the await blanks the figures
It ran as an urgent update, because React lost the Transition at the await. Wrap it in its own startTransition.
Let the screen you have stand in
The first report treated the next screen as more important than the current one, so it threw the current one away before the next existed. Splitting the click says what people need at each moment: proof that the click landed, the figures they were reading until better ones arrive, and a word that those are on their way. In the finished report none of that is a timer or a flag of your own. The button moves, the old figures stand in, and the Action’s pending state lasts exactly as long as its request.