Keeping the stream out of state
Accumulate chunks in a ref and flush to React on a schedule you choose — decoupling arrival rate from render rate is the change that moves INP when input delay is the dominant phase, and the profiling lesson’s three-way split is what tells you whether that is your case.
Two lessons have told you what does not work. Here is the one that does, with a condition attached that the rest of this lesson is careful about.
The mechanism is simple enough to state in a sentence. The model decides how often chunks arrive. React should not. Put the accumulating text somewhere React does not watch, and flush it into state on a schedule you choose — once a frame, or slower.
Read this before you apply it
This is the fix for one of the three phases the profiling lesson taught you to distinguish. If your trace named input delay as the dominant phase — the stop click waiting behind a queue of renders — this is the change that moves your number, and it is the first one to try. If your trace named presentation delay, caused by an enormous DOM to lay out or by re-parsing the whole transcript on every chunk, this will reduce the render count and leave the dominant phase roughly where it was. That case belongs to the payload module. Applying this fix to a presentation-delay problem and then reporting an improvement is how a performance win gets announced that did not happen.
The guarantee the pattern rests on
React’s useRef reference is unambiguous:
Changing a ref does not trigger a re-render.
And it explains why, which matters more than the rule itself:
When you change the
ref.currentproperty, React does not re-render your component. React is not aware of when you change it because a ref is a plain JavaScript object.
That is the entire lever. Appending a chunk to ref.current is an assignment to an object property. React never hears about it, schedules nothing, and the main thread stays free for the click that is about to arrive.
The shape
const bufferRef = useRef('')
const [displayed, setDisplayed] = useState('')
function onChunk(text) {
bufferRef.current += text // no render
}
useEffect(() => {
let id = requestAnimationFrame(function tick() {
setDisplayed(bufferRef.current) // one render per frame, not per chunk
id = requestAnimationFrame(tick)
})
return () => cancelAnimationFrame(id)
}, [])Three properties are worth naming. Chunk arrival and render are now independent: a burst of twenty chunks in one frame produces one render, not twenty. The render rate has an upper bound set by the display rather than by the model. And the flush is cancellable, so when the user clicks stop you tear down the loop rather than draining a queue.
requestAnimationFrame is one scheduling choice, not the only one. An interval on a coarser tick renders less often and reads as chunkier text; a frame-aligned flush reads smoothly and renders more. Which reads better is a design judgement you make with the surface in front of you. Which measures better is a number you take twice. Do not assume they agree.
Where people get burned
The same reference carries the caveat that turns this pattern into a bug: “Do not write or read ref.current during rendering… Reading or writing a ref during rendering breaks these expectations.” An accumulator in a ref invites exactly that mistake, because bufferRef.current holds the freshest text and putting it straight into JSX looks like an optimisation. It is not. The render output would then depend on a value React did not track, which is unsound under concurrent rendering and produces tearing that reproduces on nobody’s machine but the user’s. Render displayed. Read the ref only inside handlers and effects.
This pattern is not exotic
It is what production chat SDKs ship. Vercel’s AI SDK exposes a throttle option on its useChat hook for this exact purpose: decoupling how fast the stream arrives from how fast React updates. The option was introduced under an experimental_ prefix and has been renamed since, so check the name against the SDK’s own documentation for the version you have installed rather than copying a name from a blog post. Note also who is publishing: Vercel documents this SDK and sells the platform it is built to run on. The useful signal is not the recommendation, it is that a vendor shipping a widely used chat hook found this necessary enough to add a knob for it.
Reaching for a library’s throttle option is a reasonable move if you already use that library. Build the pattern by hand at least once anyway. A throttle interval you cannot explain is a number you cannot defend when the host team asks why the text feels chunky.
What this does to the number, and what it does not
Return to the three-phase split from the profiling lesson, because the honest description of this fix is phase by phase:
- Input delay. This is the target. Fewer renders means a shorter queue in front of the click, and the click’s callbacks start sooner. Expect the largest movement here.
- Processing duration. Unchanged. Your stop handler does the same work it always did.
- Presentation delay. Partly improved, and only partly. Fewer commits means fewer chances for a frame to be blocked, but the transcript is still the same size and still costs the same to lay out and paint once it is large. If the DOM is the problem, the DOM is still the problem.
Which is why the after column in MEASUREMENTS.md asks two questions rather than one: what the total did, and whether the phase that dominated before still dominates. A total that improved while the same phase still dominates means you reduced the magnitude and not the mechanism. Say so.
Check your recall
Answer from memory — no scrolling back.
Retrieval check
You ship the ref-and-flush pattern and the render count collapses, but INP barely moves. Name two explanations that are both consistent with the measurement.
Check your answer
The dominant phase was never input delay. Check the phase split, not the total. If presentation delay was and still is the largest slice, you have fixed something real and it was not the bottleneck. The payload module is where that case goes, and the measurement you just took is the evidence that sends you there rather than a guess.
Each remaining render still contains a long task. Reducing a hundred renders to sixty is worth little if every one of them re-parses the entire accumulated markdown and re-highlights every code block. The count came down; the per-render cost did not, and it is the per-render cost that produces the long task the click waits behind. The memoization lesson’s per-message boundaries and the payload module’s parsing lesson both attack that.
Both explanations are reasons to keep the change and keep looking, not reasons to revert it. Record the phase split next to the total so the next person can tell which one you were in.
Hands on
Fill the after column
Done when: MEASUREMENTS.md has a complete “After — stream out of React state” section: same prompt, same click timing and the same CPU throttling preset as the before column, with the four INP numbers, the render count, and an explicit answer to whether the phase that dominated before still dominates.
- Move the accumulator into a ref and flush on a schedule. Keep the scheduling choice in one named function so you can change the cadence later without touching the stream handler.
- Search the component for any read of
ref.currentin the render body. Move each one into a handler or an effect. Do this before you measure, because a tearing bug found after a celebratory number is worse than one found before it. - Re-read the before column. Note the exact prompt, the click timing (early, middle or late in the stream) and the throttling preset. A different prompt makes the two columns incomparable, and the fix is not a fix you can report.
- Record a trace under identical conditions: same preset, same prompt, click stop at the same point in the stream. Read the phase split from the Interactions track and write all four numbers into the after column.
- Write the render count too, from the Profiler counter you added in the compiler lesson. The count is the thing this change was aimed at; the INP is the thing you care about. Recording both is what lets you tell a fix that worked from a fix that fired.
- Answer the file’s own question in one line: did the phase that dominated before still dominate after? If yes, say that you reduced magnitude rather than mechanism, and name which module handles the mechanism.
- Bring both columns into the chat. I will push back if the throttling presets differ, if the prompt changed, or if the after column reports a total without a phase split.
What this does not cover
The pattern above keeps a mutable buffer outside React and copies it into state on a tick. That copy is a compromise: the buffer is the truth and the state is a stale snapshot of it, and there is a sanctioned way to let the tree read the buffer itself without the copy and without breaking the rules. That is the useSyncExternalStore lesson, and it comes with a contract you have to get exactly right.
Choosing a lower priority for the flush rather than a slower cadence is the other direction, and it has a failure mode of its own when the low-priority work restarts on every chunk. The transitions lesson closes this module on that.
Nothing here reduces the size of the transcript or the cost of turning it into DOM. If your phase split still points at presentation delay after this change, the payload module is the honest next stop: virtualizing a long conversation, and the cost of parsing every token. And the stream itself — how chunks arrive, how the request is aborted when the user clicks stop, what happens on reconnect — stays with the Streaming interfaces course. This lesson assumes the bytes are already arriving and asks only what React does with them.
Read this next — primary source
useRefreact.dev — free; fetched 5 September 2026, no revision date shown on the page. Meta documenting its own library.
This lesson takes the one guarantee the whole pattern rests on — that changing a ref does not trigger a re-render — and the caveat that stops the pattern turning into a subtle bug. The full reference adds what a lesson has to compress: why a ref is not a substitute for state when the value is rendered, the ref-callback form, how refs interact with Strict Mode’s double-invocation, and the reasoning behind the do-not-read-during-render rule. Read the Caveats section before you write the accumulator, not after.
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.