Tokens are streaming into your chat UI and the whole message list re-renders on every chunk. How do you fix it?
Advertisement
🧩 Scenario
Architecture Walkthrough
Why the whole list re-renders
The naive version looks like this: messages lives in useState, and every chunk does setMessages(prev => ...) to append text to the last message. React re-renders the parent, and the parent maps over every message. Without memoization, every MessageRow renders again, even the 49 that have not changed. Add markdown parsing or code highlighting inside each row and the cost grows with the length of the conversation.
There are two separate problems here:
- Too many updates. A model can send many small chunks per second. You do not need to render more often than the screen refreshes.
- Too much work per update. Each update renders rows that did not change.
Fix 1: render only what changed
Treat finished messages as immutable. When a chunk arrives, create a new object only for the streaming message and keep every other message object exactly as it was. Then wrap the row in React.memo and key it by a stable id, never the index. React compares props with Object.is, so unchanged messages skip rendering.
An even clearer split: keep the finished history in one state and the streaming text in another. The history list is memoized and does not change while tokens arrive. Only a small StreamingBubble component re-renders. When the stream ends, you move the final text into history in a single update.
Fix 2: batch the chunks
Collect incoming text in a useRef (a ref change does not trigger a render) and flush it to state inside requestAnimationFrame. That caps renders at roughly one per frame, no matter how fast chunks arrive. It is the same idea as throttling a scroll handler, applied to a stream.
If you use the Vercel AI SDK, useChat has a throttle option for this. useDeferredValue is another tool, but the React docs note it only helps when the child is wrapped in memo.
Measure, do not guess
Open the React DevTools Profiler, record while a long answer streams, and check which components rendered. After the fix, you should see only the streaming bubble lighting up.
Key Code Explained
import { memo, useCallback, useEffect, useRef, useState } from 'react';
type Message = { id: string; role: 'user' | 'assistant'; text: string };
// 1. Finished rows skip rendering when their message object is unchanged
const MessageRow = memo(function MessageRow({ message }: { message: Message }) {
return <div className={`msg msg-${message.role}`}>{message.text}</div>;
});
// 2. Collect chunks in a ref, flush at most once per animation frame
function useStreamBuffer(onFlush: (text: string) => void) {
const bufferRef = useRef('');
const frameRef = useRef<number | null>(null);
const push = useCallback(
(chunk: string) => {
bufferRef.current += chunk;
if (frameRef.current !== null) return; // a flush is already scheduled
frameRef.current = requestAnimationFrame(() => {
frameRef.current = null;
const text = bufferRef.current;
bufferRef.current = '';
onFlush(text);
});
},
[onFlush],
);
useEffect(() => () => {
if (frameRef.current !== null) cancelAnimationFrame(frameRef.current);
}, []);
return push;
}
export function Chat({ history }: { history: Message[] }) {
// 3. Streaming text lives apart from the finished history
const [streamingText, setStreamingText] = useState('');
const appendText = useCallback((text: string) => {
setStreamingText((prev) => prev + text);
}, []);
const pushChunk = useStreamBuffer(appendText);
// call pushChunk(value) inside your reader loop instead of setState per token
return (
<div className='chat'>
{history.map((m) => (
<MessageRow key={m.id} message={m} />
))}
{streamingText && (
<div className='msg msg-assistant' aria-busy='true'>
{streamingText}
</div>
)}
</div>
);
}
MessageRow re-renders only when its message reference changes. useStreamBuffer turns a burst of chunks into one state update per frame. Because streamingText is separate from history, the history map produces the same row elements with the same props, and every memoized row bails out.
Tradeoffs
| Approach | Benefit | Cost |
|---|---|---|
| React.memo on rows | Finished rows skip rendering | Breaks silently if props get new references |
| Separate streaming state | Very clear isolation | One extra step to move text into history at the end |
| requestAnimationFrame batching | Caps renders per frame | Small custom hook to maintain and clean up |
| Library throttle option | One line of config | Tied to that library's update model |
| Virtualization | Cheap DOM for huge histories | Harder scroll and height handling while streaming |
What Interviewers Actually Check
- Whether you can explain why one growing message re-renders the whole list
- Whether you know
React.memodepends on stable references, not just wrapping a component - Whether you limit update frequency instead of rendering per token
- Whether you key rows by a stable id
- Whether you would profile before and after
Follow-Up Questions
- How would you handle this if the state lives in Redux or Zustand instead of
useState? - What changes if each finished message renders expensive markdown and syntax highlighting?
- How would virtualization interact with auto-scroll while an answer streams?
- When would
useDeferredValuehelp here, and when would it not? - How would you test that finished messages do not re-render?
Common Candidate Mistakes
- Saying "use React.memo" without making sure unchanged messages keep the same object reference
- Using
key={index}, which ties row identity to position instead of the message - Throttling with
setTimeoutbut forgetting to flush the remaining text when the stream ends - Passing a new inline
onCopy={() => ...}to every memoized row, so memo never bails out - Optimizing without measuring and then not knowing if anything improved
Interview Readiness Checklist
Before you leave this question, make sure you can answer:
- Can you explain the two causes: too many updates and too much work per update?
- Can you write a memoized row and keep its props stable?
- Can you batch chunks with
requestAnimationFrameand clean it up on unmount? - Can you separate streaming text from finished history?
- Can you show the result in the React Profiler?
Summary
When tokens stream into one big messages state, every chunk re-renders every row. Fix it in two directions. First, reduce update frequency by buffering chunks in a ref and flushing once per animation frame (or with your library's throttle option). Second, reduce work per update by keeping finished messages immutable, keying rows by id and wrapping them in React.memo, or by holding the streaming text in its own state so the history never changes mid-stream. Verify the result with the React Profiler, and add virtualization only if a long history is still slow.
Is React.memo alone enough?
Only if the props of finished messages keep the same reference. If every update rebuilds the whole messages array with new objects (for example with a deep clone or a fresh map that spreads every message), memo compares new objects and re-renders everything anyway.
Should I virtualize the message list?
Virtualization helps when the conversation is very long and the DOM itself is the cost. It does not fix the per-token problem. Fix update frequency and memoization first, then add virtualization if a long history is still slow.
Does the Vercel AI SDK help with this?
The useChat reference documents a throttle option (in milliseconds) that reduces how often message state updates in React, without delaying stream processing. You still need memoized rows so finished messages skip rendering.
Advertisement
Stay Updated
Get the latest frontend challenges, interview questions and tutorials delivered to your inbox.
Advertisement