A2 · Part 3 · Advanced
Concurrent features
Telling React which updates can wait — so the urgent one never queues behind them.
12On this page
- The problem
- startTransition
- useTransition versus startTransition
- useDeferredValue
- Activity : keeping a subtree alive but hidden
- Automatic batching
- Transitions in routing
- What "interruptible" means in milliseconds
- Reading it in DevTools
- Separate transitions stay separate, since React 19.3
- The catch worth knowing
- Where this shows up without you asking
The problem
Type into a search box that filters ten thousand rows. Every keystroke sets two pieces of state: the input value, and the filtered list. React renders both together, the filtering takes 200ms, and the character you typed appears a fifth of a second later. The input feels broken.
Nothing here is fixable by making the filter faster — the problem is that both updates were treated as equally urgent. Concurrent features let you say they are not.
startTransition
const [isPending, startTransition] = useTransition();
function handleChange(e) {
setQuery(e.target.value); // urgent: show the character
startTransition(() => {
setResults(filter(e.target.value)); // can wait, can be abandoned
});
}
The input updates immediately. The list update runs at lower priority, and if another keystroke arrives mid-render React throws that work away and starts again with the newer value. You never render a list for a query the user has already moved past.
isPending is true while the transition is in flight — use it
to dim the stale results rather than to show a spinner. The old content
staying visible is the point.
useTransition versus startTransition
const [isPending, startTransition] = useTransition(); // the hook
import { startTransition } from "react"; // the standalone
useTransition | startTransition | |
|---|---|---|
Gives you isPending | yes | no |
| Callable outside a component | no — it is a hook | yes |
| Use it | when the UI shows pending state | in a store, a router, an event outside React |
<div style={{ opacity: isPending ? 0.6 : 1 }}>
<Results items={results} /> // dim the stale list, do not blank it
</div>
isPending is the reason to prefer the hook. Replacing the results
with a spinner throws away content the user was reading; dimming it says
"working" without taking anything away. That distinction is most of what makes
a transition feel different from a loading state.
Async work inside a transition keeps isPending true until the
awaited work settles, so an action that saves and then updates state stays
pending across the whole operation rather than flickering between the two.
useDeferredValue
const deferred = useDeferredValue(query);
const results = useMemo(() => filter(deferred), [deferred]);
<input value={query} onChange={...} /> // always current
<Results items={results} /> // lags behind, deliberately
Same outcome from the other end. Instead of marking the update, you mark the value: React renders once with the old value at high priority, then again with the new one at low priority.
| Use | When |
|---|---|
startTransition | You control the state update — you can wrap the setter |
useDeferredValue | The value arrives as a prop, or the update is not yours to wrap |
Not a debounce
A debounce waits a fixed time and hopes it is enough. A transition starts immediately and yields only when something more urgent appears — so on a fast machine the results are instant, and on a slow one the input still never stutters. It adapts; a 300ms timeout does not.
<Activity>: keeping a subtree alive but hidden
<Activity mode={tab === "chat" ? "visible" : "hidden"}>
<ChatPanel />
</Activity>
Stable since React 19.2. A hidden Activity is not unmounted — its state, scroll position and DOM stay alive, its effects are torn down, and React renders it at the lowest priority in the background. Switching back is instant and nothing was lost.
| Approach | State kept | Effects run | Cost |
|---|---|---|---|
| Unmount it | lost | no | rebuild on return |
display: none | kept | yes — still running | timers and subscriptions stay live |
<Activity mode="hidden"> | kept | torn down | memory only |
The middle row is what most tab implementations do today, and it is why a hidden panel keeps polling. Activity is the first API that gives you "keep the state, stop the work" without writing it yourself — and it can pre-render a hidden route so the next navigation has nothing left to do.
Automatic batching
setA(1); setB(2); // one render, always
setTimeout(() => { setA(1); setB(2); }); // one render since React 18
fetch(url).then(() => { setA(1); setB(2); }); // one render since React 18
Before 18, batching only happened inside React event handlers; updates in
timeouts, promises and native handlers each rendered separately. Now
everything batches. flushSync opts out for the rare case where you
must read the DOM between two updates.
Transitions in routing
startTransition(() => navigate("/dashboard"));
Wrapping a navigation keeps the current page interactive while the next one renders, rather than blanking to a spinner the moment the link is clicked. Modern routers do this for you; knowing why matters when you have to debug it.
What "interruptible" means in milliseconds
React works in slices of roughly five milliseconds, then checks whether it should yield. If the browser has input waiting, React stops, lets the browser handle it, and resumes afterwards — possibly discarding what it had if that input made the work irrelevant.
Which sets the limit. React can only yield between units of work. One component that spends 300ms in a single synchronous loop offers no yield point, so a transition cannot help it. The fix there is to make the unit smaller: memoise the calculation, virtualise the list, or move the work to a Web Worker.
Reading it in DevTools
React 19.2 adds custom tracks to the Chrome performance panel: a Scheduler track showing which priority React was working at, and a Components track showing render and effect phases per component.
What to look for: a long unbroken block at high priority is work that should have been a transition; repeated identical renders in the Components track is something re-rendering that did not need to. Both are visible there and nowhere else.
Separate transitions stay separate, since React 19.3
Until 19.3, React entangled concurrent transitions into a single render. Start a slow one — a filter over a large list — and then a quick one — switching a tab — and the tab switch could not commit until the filter was finished too.
startTransition(() => setFilter(q)); // slow: re-renders 10,000 rows
startTransition(() => setTab("settings")); // fast: no longer waits for the filter
React 19.3 renders transitions independently, so a slow transition no longer holds up unrelated ones. Nothing in your code changes. What changes is the debugging: if you profiled an app on 19.2 and saw a quick update stuck behind a slow one, that was the entanglement, and upgrading is the fix.
The catch worth knowing
A transition can be interrupted and restarted, so anything inside it may run
more than once. That is fine for pure state updates and wrong for anything
with a side effect — do not put a fetch or an analytics call inside
startTransition.
It also cannot help with a single synchronous block that is slow. Transitions let React yield between units of work; one component that spends 300ms in a loop still blocks, because there is no yield point inside it. Fix that with memoisation, virtualisation, or a Web Worker.
Where this shows up without you asking
- Suspense uses transitions to avoid replacing visible content with a fallback.
- Server Components stream in at transition priority.
- Framework routers wrap navigation in one already.
Interview answer, one sentence
"Concurrent features are a priority system: startTransition marks
an update as interruptible so an urgent one — a keystroke — is never queued
behind it, and React abandons in-progress low-priority work when newer input
arrives. It is not a debounce, because it yields on demand rather than
waiting a fixed time."
Opens in the editor: write it, run it, and check it against real tests.
All 54 for this level →Finished reading?
0 of 57 chapters read in this series