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 useTransition make 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.
Call it at the top of a component and you get back exactly two things, always in this order.
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.
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.
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.
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.
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.
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.
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.
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 to walk through.
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.
