useTransition: Deferring Non-Urgent State Updates
useTransition is the hook developers reach for first when they learn about concurrent React, and
for good reason: it is the most direct way to say, “this state change is allowed to wait.” The API
is small—[isPending, startTransition]—but the behavior touches everything from list filtering to
tab switches to async Server Actions. Used well, it keeps typing and clicking crisp; used blindly,
it adds renders without a perceptible win.
The API in one breath
startTransition wraps a function that updates state (or triggers other transition updates). React
treats those updates as non-urgent. They may run after urgent updates, and in-flight
transition renders can be discarded if newer urgent or transition work supersedes them.
isPending is true while such deferred work has not yet fully flushed to the screen—an honest
signal for lightweight loading UI.
const [isPending, startTransition] = useTransition();
The hook is not a generic async primitive. It is a scheduling primitive: you are reclassifying work, not creating a new thread.
Search: instant input, deferred results
The canonical pattern separates what the user sees immediately from what can trail behind:
function Search({ bigList }: { bigList: string[] }) {
const [query, setQuery] = useState("")
const [results, setResults] = useState<string[]>([])
const [isPending, startTransition] = useTransition()
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setQuery(e.target.value)
startTransition(() => {
const filtered = bigList.filter((item) =>
item.toLowerCase().includes(e.target.value.toLowerCase())
)
setResults(filtered)
})
}
return (
<>
<input value={query} onChange={handleChange} />
{isPending && <p>Updating results...</p>}
<ul>
{results.map((r) => (
<li key={r}>{r}</li>
))}
</ul>
</>
)
}
query updates on the urgent path, so the controlled input never fights the scheduler. results
updates inside a transition, so if filtering and reconciliation are expensive, React can keep the
field responsive and optionally show isPending while the list catches up.
What happens when the user types quickly
Picture this timeline:
- The user types
a. The urgentsetQueryruns. A transition begins to filter and render the list. - Before that transition finishes, the user types
b. Another urgent update arrives.
React may interrupt the first transition render—there is no point committing a heavy list for
a when the authoritative query is already ab. A new transition starts from the latest state. The
old work is abandoned. That is the same interruptibility described in the scheduler section, now
visible in application code.
This cancellation behavior is a feature for freshness, not a guarantee about how many times your
reducer or memo callbacks will run. Side effects still belong in useEffect or event handlers, not
inside render paths that might restart.
Batching multiple updates inside one transition
startTransition can wrap several setState calls. They tend to be treated as one conceptual
deferral, which is useful when updating multiple pieces of derived UI together—active tab, panel
content, and auxiliary metrics—without each update competing as urgent.
function DashboardTabs() {
const [tab, setTab] = useState<"summary" | "detail">("summary")
const [panelData, setPanelData] = useState<PanelData | null>(null)
const [isPending, startTransition] = useTransition()
const selectDetail = () => {
startTransition(() => {
setTab("detail")
setPanelData(computeHeavyPanel())
})
}
return (
<div>
<button type="button" onClick={selectDetail} disabled={isPending}>
Open detail
</button>
{isPending ? <span>Preparing…</span> : null}
</div>
)
}
The button disabling is optional; sometimes a subtle pending indicator is enough. The key is that the expensive path is not competing with unrelated urgent input on the same priority plane.
The same idea applies when switching tabs that mount different feature bundles, or when
promoting a chart or map to a new dataset. Keep the tab label or legend on the urgent path so
the click feels instant; wrap the state that swaps in the heavy visualization in startTransition.
If the user changes their mind and clicks another tab before the first transition finishes, React
can drop the in-flight work for the abandoned panel—exactly the scheduler behavior you want for
exploratory navigation.
Async transitions and Server Actions (React 19)
React 19 extends transitions to async functions. You can write:
startTransition(async () => {
await createRecordOnServer(formData);
});
During the async operation, isPending remains true for the whole span—not only while React is
rendering, but also while the promise is unresolved. That makes transitions a natural pairing for
Server Actions: the UI can show a single pending state across network round-trips and subsequent
re-renders when fresh data arrives.
TypeScript teams should model server functions with explicit return types so callers know whether
errors propagate as rejected promises or structured result objects. Combine that with error
boundaries or local try/catch in the transition for predictable failure UX.
const [isPending, startTransition] = useTransition();
const handleSave = () => {
startTransition(async () => {
try {
await saveProfileAction(payload);
} catch {
toast.error("Could not save profile");
}
});
};
The transition still coordinates scheduling; it does not replace validation, idempotency, or retry policy on the server.
When not to use useTransition
Skip transitions when updates are cheap and already fast. Avoid wrapping every setState “just
in case”—you may increase concurrent churn without improving interaction latency. Also be cautious
when users depend on immediate consistency between two visible fields that you split across
urgent and transition updates; test that intermediate frames still make sense.
From a field metrics perspective, transitions help with interaction-to-next-paint style goals:
the browser gets control back sooner for pointer and keyboard handlers, even if a large subtree is
still catching up. They are not a substitute for shrinking that subtree, splitting code with lazy,
or moving aggregation to a worker or server when frames are still tight on low-end hardware.
Design checklist for production
- Separate urgent display state (what must mirror input) from deferred derived state (lists, charts, secondary panels).
- Use
isPendingintentionally—either disable destructive double-submits or show non-blocking feedback. - Expect restarts under rapid input; keep render pure and idempotent.
- Pair with data fetching thoughtfully—a transition does not magically stream data; it coordinates priority around the updates that follow fetch completion.
useTransition is the hook you use when you own the update and can wrap it. When the hot value
arrives as a prop from a parent you do not control, the next section’s tool—useDeferredValue—often
fits better.