Transitions under a stream that never stops
Concurrent rendering is built to interrupt low-priority work for user input, which is exactly the guarantee a stop button needs — and exactly the guarantee that degrades when the low-priority work restarts on every chunk.
You have the render count down and the store publishing on a cadence you chose. One question is left, and it is the one the stop button has been asking since the first lesson: when a click arrives while React is halfway through rendering a transcript, who wins?
React has an answer, it is a real guarantee, and it degrades in a specific way under a stream that never stops. Both halves matter, and most writing about this covers only the first.
The guarantee
From react.dev’s useTransition reference:
A state update marked as a Transition will be interrupted by other state updates. For example, if you update a chart component inside a Transition, but then start typing into an input while the chart is in the middle of a re-render, React will restart the rendering work on the chart component after handling the input update.
Substitute the chart for your transcript and the typing for a stop click, and that paragraph is the feature this module has been working toward. Urgent input preempts low-priority render work. The click is handled first, the transcript render resumes afterwards.
const [isPending, startTransition] = useTransition()
function onFlush(next) {
startTransition(() => setTranscript(next)) // interruptible by the stop click
}The degradation
Read the last four words of the quote again: React will restart the rendering work. Not resume. Restart.
The useDeferredValue reference states the same mechanism from the other side and takes it to its conclusion:
The background re-render is interruptible: if there’s another update to the value, React will restart the background re-render from scratch. For example, if the user is typing into an input faster than a chart receiving its deferred value can re-render, the chart will only re-render after the user stops typing.
Now substitute again, and notice that the substitution is not the one you expected. It is not the user typing. It is the model streaming. If the deferred or transitioned value changes on every flush, and a flush arrives before the low-priority render finishes, the render restarts from scratch. Flush often enough and it may never commit at all.
The documented consequence for the chart is that it renders only after the typing stops. The consequence for your transcript is that it renders only after the stream stops, which is a frozen answer with a cursor blinking at the top of it. React is behaving exactly as documented. Your flush cadence made the low-priority work unschedulable.
Where people get burned
This is why the previous two lessons come first. A Transition on top of a per-chunk update is a worse configuration than no Transition at all: you get restarted work rather than completed work, and the symptom is a transcript that appears to stop updating, which reads to a user as a hung request. Transitions are safe here only once something upstream has already bounded how often the value changes.
What a Transition is not
The useDeferredValue page is direct about the boundary, and the sentence is worth memorising because it stops a whole class of bad proposals: deferring a value “does not make re-rendering… faster,” it tells React that the re-render can be deprioritised.
Transitions and deferred values are scheduling tools. They change the order in which work happens and what can interrupt what. They do not make a render cheaper, they do not reduce the number of renders, and they cannot help a phase that is not about competition for the main thread. Line them up against the three phases:
- Input delay. This is where a Transition can help, because input delay is by definition the click waiting behind other work, and interruptibility is a claim about exactly that.
- Processing duration. Untouched. Your handler runs at the same speed.
- Presentation delay. Mostly untouched, and possibly worse, since restarted background renders are work the browser did and threw away.
The async limitation you will hit
One documented restriction bites immediately in stream code. Per the reference, you must wrap any state updates that happen after an async request in another startTransition to mark them as Transitions. React describes this as a known limitation it intends to fix.
A stream handler is nothing but state updates after an async request. The update you issue inside a .then(), or after an await on the next chunk, is not a Transition just because the function that started the read was wrapped in one.
// Not a Transition: the await ends the scope
startTransition(async () => {
const chunk = await reader.read()
setTranscript(chunk.value) // urgent, despite appearances
})
// A Transition: re-wrap after the await
const chunk = await reader.read()
startTransition(() => {
setTranscript(chunk.value)
})This one is hard to catch by reading, because both versions compile, both run, and the difference shows up only as a priority you thought you had assigned and did not. Verify it in a profile rather than in review.
Check your recall
Answer from memory — no scrolling back.
Retrieval check
Given everything in this module, when is marking the transcript update as a Transition actually the right call?
Check your answer
When the flush rate is already bounded and each flush still produces a render long enough to be worth interrupting. That is the shape a Transition was built for: work that is genuinely expensive, happening at a rate low enough that it can finish between updates, and that you are willing to have preempted by a click.
A per-frame flush over a long transcript can fit that description. A per-chunk flush cannot, because the restart window is shorter than the render. The ordering rule that falls out of this is worth stating on its own: bound the update rate first, then assign priority. The two changes are complementary in that order and actively harmful in the other.
And check the measurement rather than the reasoning. If your phase split does not show input delay, priority is not your problem and a Transition is a change you cannot justify with a number.
Hands on
Find the flush rate at which the Transition stops paying
Done when: MEASUREMENTS.md carries INP with its phase split at three flush cadences — per chunk, per frame, and one slower interval — each run with the transcript update marked as a Transition, at the same throttling preset, plus a one-line statement of which cadence you shipped and why.
- Make the flush cadence a single configurable value in the store you built in the previous lesson. You are about to change it three times and everything else has to stay identical.
- Wrap the flush’s state update in
startTransition. Check the async limitation: if the update happens after anawait, wrap it again at that point. Confirm in a profile that the update really is low priority rather than assuming it from the source. - Run the same prompt at each of the three cadences. For each run, record INP and all three phases, and note whether the transcript visibly updated during the stream or only at the end.
- Add the observation the numbers do not capture: at which cadence did the text stop appearing progressively? That is the restart threshold for your app on your machine, and it is a property of your render cost rather than a figure anyone can publish for you.
- Compare the best of the three against the after column from the keeping-the-stream-out-of-state lesson. If the Transition did not improve on it, say so in the file and leave it out of the shipped code. A scheduling primitive that did not move the number is complexity you would be asking a host team to maintain.
- Write one line in the log naming the cadence you shipped and the evidence for it. Then update the budget check table, since this is the point at which the INP row either meets its threshold or does not.
- Bring the three phase splits into the chat. I will push back if the runs used different prompts or throttling presets, or if the Transition was never verified as applied.
What this does not cover
That closes the render-cost half of this course. You can now say which of the standard React fixes applies to which phase, and prove it: memo boundaries and the compiler reduce what a render costs, the ref-and-flush pattern reduces how often renders happen, the external store makes that readable without tearing, and Transitions decide what a click is allowed to interrupt.
None of it makes the transcript smaller. If your phase split still names presentation delay after all of this, the remaining cost is the DOM itself and the work of turning tokens into it. That is the payload module: what you ship into the host’s bundle, virtualizing a conversation whose last message grows while it is on screen, and the cost of re-parsing markdown on every chunk. Those are the lessons for a trace that this module could not move.
And the stream itself is still not this course’s subject. Transports, event shapes, aborting the request the stop button fires, resuming after a refresh, retry and backoff — all of that is the Streaming interfaces course. This one has only ever asked what the browser is spending once the bytes are already arriving, and what evidence you can put in front of a team whose page you are a guest on.
Read this next — primary source
useTransitionreact.dev — free; fetched 5 September 2026, no revision date shown on the page. Meta documenting its own library.
This lesson takes the interruption guarantee in React’s own words and the documented limitation around async updates. The full reference adds what a lesson has to compress: the isPending flag and where showing it is worth the extra render, how Transitions interact with Suspense boundaries and error boundaries, why an update inside a Transition still cannot be used for a controlled input, and the several worked troubleshooting cases for updates that do not behave as Transitions. Read the Troubleshooting section before you conclude your Transition is not working.
Stuck, curious, or think this lesson is wrong? Ask your teaching agent. The lessons are the scaffold; the conversation is where the learning gets unstuck.