Every hook in this series so far has been a passenger. useActionState rides inside a Transition and reports back the result. useOptimistic rides inside one and shows you a preview before the real answer lands. useFormStatus doesn't even ride, it just reads a status from whoever else is driving. useTransition is the only one of the four that sits in the driver's seat by itself, no form required, no other hook required.
That's also why it's the easiest one to get partially right. You wrap a click handler in startTransition, you get an isPending flag, everything looks fine in your browser tab, and you ship it. Then someone double-clicks fast, or your function throws, or you try to hook it up to a text input, and the parts nobody mentioned start showing up.
This is a standalone guide. If you're new to this series, everything below works on its own. If you've read Part 4, you already saw
useTransitionmake a brief appearance there through a wishlist button.
I tested every example below against React 19.2.8. I also re-ran the race condition, error boundary, and overlapping Transition checks on React 19.3.0, which shipped on September 9.
What useTransition Actually Gives You
Call it at the top of a component and you get back exactly two things, always in this order.
import { useTransition } from "react";
function ProductFilters() {
const [isPending, startTransition] = useTransition();
// ...
}
isPending is a boolean. It becomes true at the first call to startTransition, and stays true until every Action in that Transition, including anything you awaited, has completed and the resulting state is shown. startTransition is a function that takes one argument, a function of your own, and runs it right away. Nothing about startTransition is delayed or scheduled for later. What changes is how React treats any set call that happens while your function is running, those updates get marked as low priority and interruptible instead of urgent.
That's the entire contract. No result value, no built-in error state, no queue. Compare that to useActionState from Part 1, which hands you back whatever your function returns as state. useTransition doesn't do that. If your function returns something, that return value is simply gone unless you store it yourself. This is the tradeoff for using useTransition directly: less structure, but nothing standing between you and the raw mechanism.
A Search Filter That Doesn't Freeze the List
Forms and buttons aren't the only place a Transition earns its keep. Filtering a long list as someone types is a case useActionState was never built for, there's no side effect to run, no single result to track, just a rendered list that needs to stay responsive while it re-renders on every keystroke.
One precision point before the code: startTransition does not make a computation run in the background. The function you pass to it runs immediately and synchronously, right when you call it. What gets marked as low priority and interruptible is the state update, and the render that follows from it, not whatever JavaScript happens to run inside the callback before that update fires. That distinction is the whole point of this example, so the two state variables below are split around it deliberately.
import { useState, useTransition } from "react";
function ProductSearch({ products }) {
const [query, setQuery] = useState("");
const [filterQuery, setFilterQuery] = useState("");
const [isPending, startTransition] = useTransition();
function handleChange(e) {
const value = e.target.value;
setQuery(value);
startTransition(() => {
setFilterQuery(value);
});
}
const filtered = products.filter((p) =>
p.name.toLowerCase().includes(filterQuery.toLowerCase()),
);
return (
<div>
<input
value={query}
onChange={handleChange}
placeholder="Search products"
/>
<ul style={{ opacity: isPending ? 0.6 : 1 }}>
{filtered.map((p) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
</div>
);
}
query drives the input and updates synchronously, so typing never lags. filterQuery is the one wrapped in startTransition, it "lags behind" query on purpose. filtered isn't stored in state at all, it's a plain expression computed fresh on every render from filterQuery. The key point: the products.filter() call itself still runs as an ordinary, uninterrupted piece of JavaScript, exactly like it would outside a Transition. What changed is when that render happens and whether a more urgent update, another keystroke, is allowed to cut in front of it. React isn't chunking your array iteration, it's deciding whether the render containing that iteration gets to proceed right now or gets pushed behind something more urgent.
This is also where the input-in-a-Transition limitation becomes concrete instead of abstract. You cannot wrap query itself in startTransition, only filterQuery can go there. A controlled input's value has to update synchronously with every keystroke to feel correct, and React's own troubleshooting docs name this exact two-state split as one of two fixes, the other being useDeferredValue on a single state variable.
When to reach for useDeferredValue instead. The rule of thumb is about who owns the set function. Here, the component owns setFilterQuery directly, so useTransition is the right tool. If ProductSearch instead received its query as a prop from a parent, or read it from some other hook you don't control the setter for, there'd be no set call of your own to wrap in startTransition. That's exactly the case useDeferredValue exists for, you hand it the value, const deferredQuery = useDeferredValue(query), and it produces its own lagging copy without needing access to whoever's setting the original. Same lagging behavior, different entry point depending on whether you're driving the update or just receiving the value.
The await Rule, and Why It Actually Happens
Part 4 showed the pattern for handling state updates after an await inside a Transition, wrap them in a second startTransition call. What it didn't get into is why React needs that second call at all, and that's worth understanding once instead of memorizing as a rule.
function handleSave() {
startTransition(async () => {
const saved = await saveDraft(draft);
// this update is NOT part of the Transition
setStatus("saved");
});
}
React marks a set call as part of a Transition by checking a flag while your function is running synchronously. The moment your function hits await, execution yields back to the JavaScript engine, and that flag is gone by the time the code after await resumes. React has no way to know that resumed code is a continuation of the same Transition rather than something unrelated. This is a JavaScript limitation, not a bug React chose not to fix, the language doesn't yet give React a way to track "this async continuation belongs to that earlier synchronous scope." A TC39 proposal called AsyncContext would close this gap, but it isn't part of the language yet.
The fix is the same nested call every time.
function handleSave() {
startTransition(async () => {
const saved = await saveDraft(draft);
// ✅ back inside a synchronous startTransition scope
startTransition(() => {
setStatus("saved");
});
});
}
After every await, state updates that need to remain part of the Transition must be wrapped in another startTransition, as the useTransition docs describe. Two sequential awaits in one Action means two separate re-entries into startTransition for the updates that follow each one.
Pending UI With No Form in Sight
Every pending indicator earlier in this series read isPending from useActionState or pending from useFormStatus. useFormStatus reads from a parent form, and useActionState is built around the Actions you dispatch through it. useTransition's isPending is the one you get when you start the Action yourself, with no form involved. It's just a boolean tied to whatever you wrapped, which means it works for things that were never going to be a form to begin with: a favorite toggle, a sort order change, a modal that loads data before it opens.
function SortButton({ label, sortKey, onSort }) {
const [isPending, startTransition] = useTransition();
return (
<button
disabled={isPending}
onClick={() => startTransition(() => onSort(sortKey))}
>
{label}
{isPending && <span className="spinner" aria-hidden="true" />}
</button>
);
}
The button that triggered the change is the one that shows its own pending state, without a global loading flag and without prop drilling a status down from somewhere else. That's the same locality useFormStatus gave you in Part 3, just reached through a different door, one that doesn't require a <form> to walk through.
Errors in a Bare Transition
This is the gap Part 4 didn't touch, and it's a real one. useActionState gives you a place to catch expected errors as returned state. A bare useTransition call gives you nothing like that. If the function you pass to startTransition throws, there's no result state to inspect, the throw propagates up to the nearest error boundary instead.
import { useTransition } from "react";
import { ErrorBoundary } from "react-error-boundary";
function AddToCartButton({ productId }) {
const [isPending, startTransition] = useTransition();
function handleClick() {
startTransition(async () => {
const result = await addToCart(productId);
if (!result.ok) {
throw new Error("Could not add item to cart");
}
});
}
return (
<button disabled={isPending} onClick={handleClick}>
Add to Cart
</button>
);
}
export function CartButtonWithBoundary(props) {
return (
<ErrorBoundary fallback={<p>Something went wrong adding this item.</p>}>
<AddToCartButton {...props} />
</ErrorBoundary>
);
}
Worth being clear about what's actually happening here: useTransition doesn't give you a custom error state or a new API surface for errors, that part is true. But it's not accurate to call this identical to any ordinary render-time error either. React specifically added Error Boundary support for Actions, so a throw inside startTransition gets caught the way a throw in a plain event handler normally wouldn't, event handler errors have never been something a boundary catches on their own.
Two things trip people up here. First, the error boundary has to wrap the component calling useTransition, not just the button markup, an error boundary placed below the hook call won't catch anything. Second, this is an all-or-nothing tool: a thrown error takes down whatever the boundary wraps until the user or your code resets it. If you want a recoverable, inline error message instead of the whole section replaced by a fallback, don't throw, return an error value from the function and store it in state yourself. useActionState gives you that pattern for free, since its return value becomes state automatically. Plain useTransition doesn't, you'd be rebuilding a small piece of what useActionState already does.
The Race Condition Nobody's First Draft Handles
Here's the scenario Part 4 flagged and left open. A user clicks something that triggers an async Transition, changes their mind, and clicks again before the first request resolves. Both requests are now in flight. The response that arrives last is the one the component keeps, even if it belongs to the earlier click.
import { useState, useTransition } from "react";
function QuantityStepper({ itemId, initialQty }) {
const [qty, setQty] = useState(initialQty);
const [isPending, startTransition] = useTransition();
function updateQty(next) {
startTransition(async () => {
const saved = await updateCartQuantity(itemId, next);
startTransition(() => {
setQty(saved);
// if a slower, earlier request resolves after this one,
// its setQty runs last and the stale value wins
});
});
}
return (
<div>
<button onClick={() => updateQty(qty - 1)}>-</button>
<span>{qty}</span>
<button onClick={() => updateQty(qty + 1)}>+</button>
</div>
);
}
React's own reference material for this exact scenario says it plainly: Actions inside a Transition don't guarantee execution order once you await something, because the async boundary loses that ordering context the same way it loses the Transition flag. useActionState sidesteps this for its own dispatcher by queuing calls and running them one at a time, but a raw useTransition call gets no such protection.
There's actually a second problem hiding in this code, and it's easy to miss because it looks like the same bug. updateQty(qty - 1) reads qty from the render that produced this particular click handler, and setQty only fires once the network request resolves. Click + twice within that window, before the first request's response has actually come back and updated qty, and both clicks compute their next value from the same starting number, sending the same result twice instead of incrementing twice. This isn't a same-tick React batching quirk, clicks are handled as separate events and React doesn't merge them. It's simpler and more common than that: the state genuinely hasn't changed yet, because the only thing that changes it is still sitting on the wire. Fixing the request race doesn't fix this one, they need separate solutions.
import { useState, useTransition, useRef } from "react";
function QuantityStepper({ itemId, initialQty }) {
const [qty, setQty] = useState(initialQty);
const [isPending, startTransition] = useTransition();
const requestedQty = useRef(initialQty);
const latestRequestId = useRef(0);
function updateQty(delta) {
// authoritative next value, independent of what's currently rendered
requestedQty.current += delta;
const next = requestedQty.current;
const requestId = ++latestRequestId.current;
startTransition(async () => {
const saved = await updateCartQuantity(itemId, next);
startTransition(() => {
// only apply this result if nothing newer has been fired since
if (requestId === latestRequestId.current) {
setQty(saved);
}
});
});
}
return (
<div>
<button onClick={() => updateQty(-1)}>-</button>
<span>{qty}</span>
<button onClick={() => updateQty(1)}>+</button>
</div>
);
}
Two separate fixes doing two separate jobs. requestedQty is a ref that tracks the next value to send independently of the rendered qty, so it keeps accumulating correctly across rapid clicks no matter how far behind the actual render is. latestRequestId is the token that solves the original problem, a stale response checks whether it's still the most recent request before it's allowed to apply. A ref is the right tool for both because neither one needs to trigger a render on its own, they just need to survive across renders and stay current. This isn't the only way to solve the ordering half, an AbortController tied to each request works too if your fetch layer supports cancellation, though aborting only stops the client from waiting, and the server may already be processing that mutation. The token check needs nothing from your API beyond what you probably already have.
Two limits worth knowing. requestedQty advances at click time, so it assumes the server accepts the requested value as sent. If the server clamps or normalizes that value, the ref and qty can drift apart. The request token only decides which response can update the UI. It doesn't cancel the earlier request or guarantee that the server applies the updates in the order they were sent.
When to Reach for useTransition and When Not To
Reach for it when you need pending tracking for something that isn't a form submission and doesn't need a tracked result value: filtering, sorting, tab switches, toggles, navigation.
Don't reach for it for a controlled input's own value, that has to stay synchronous or typing will visibly lag. Don't reach for it when you need the function's return value as state, that's what useActionState captures automatically and useTransition doesn't. Don't reach for it assuming request ordering is handled, it isn't, not without the token pattern above or an equivalent.
Keep in mind that the useTransition reference still lists a limitation: if multiple Transitions are ongoing, React currently batches them together. React 19.3 changed something nearby. According to the React 19.3 release post, a Transition with a slow render no longer holds up unrelated ones. So don't assume several pending indicators settle independently.
Where This Leaves the Series
| You need to... | Reach for |
|---|---|
| Own a form's result and pending state in one place | useActionState |
| Show finished UI before a server response confirms it | useOptimistic |
| Read a form's pending state from a component that isn't managing it | useFormStatus |
| Run pending-tracked work outside a form, with full control yourself | useTransition |
Part 4 called these four answers to four different questions sharing one mechanism. Having now used the fourth one without any of the other three in the room, that framing holds up. useTransition isn't the beginner-friendly entry point of the group, it's the one with the fewest guardrails, which is exactly why it's also the one that can do things the form-shaped hooks were never built for.
I went through the out-of-order version of this bug in a plain search box in I Throttled My App to Slow 3G. Here's What My Tests Never Caught.
So which one have you already shipped without knowing it, a response landing out of order or two fast clicks reading the same stale value? Drop it below.
Top comments (0)