Streaming performance
What should re-render on a stream chunk, and how to verify that it does.
A streaming reply updates your messages state on every chunk. The render target is strict: one chunk re-renders the streaming row, the transcript wrapper it sits in, and nothing else. Not the composer, not finished messages, not the thread chrome. Everything on this page serves that target.
The primitives hold up their side — see the Performance sections on Thread, Message, and Composer for what each already handles and the one recipe each needs from you. This page covers the part that belongs to your app: how the chat state reaches the tree.
Split the volatile state from the stable state
The classic failure mode is one context that bundles per-chunk messages with
stable actions. Every consumer re-renders per chunk regardless of what it
reads — the composer's editor, the layout, every finished message.
Keep two contexts. The session side changes rarely and is memoized; the messages side changes per chunk and has exactly two subscribers:
type ChatSessionValue = {
chatId: string;
isEmpty: boolean; // flips once per chat — layout reads this, not messages
sendMessage: SendMessage;
regenerate: Regenerate;
// …actions: stable identities
};
type ChatMessagesValue = {
messages: UIMessage[]; // changes per chunk
status: ChatStatus;
};- Layout chrome reads
isEmptyfrom the session side — the thread frame, header, and composer dock render zero times during a stream. - The transcript reads the messages side and maps rows — see the memoized row recipe.
- The composer bridge reads the messages side, derives identity-stable panel state, and hands it to a memoized inner composer — see Composer performance.
Two details make or break the split: the session value must be built with
useMemo so its identity survives the per-chunk provider re-render, and any
component that is a direct child of the provider needs memo (a re-rendering
parent re-creates child elements even when context values are stable).
Verify with react-scan
Don't trust the architecture — watch it. Run react-scan while streaming a long reply. The healthy signature:
- counting up: the root holding your chat state, the transcript component, the streaming row and its markdown renderer, and the thin composer bridge
- at zero: the thread frame and viewport, the composer editor, the header, and every finished message row
If a finished row counts up, its memo is being defeated — almost always a
spread ({ ...message }) minting a fresh object, or an inline callback prop.
If the composer editor counts up, the bridge is passing unstable props.