Search IOCombats

Search challenges, guides, questions and articles

Tokens are streaming into your chat UI and the whole message list re-renders on every chunk. How do you fix it?

Intermediate10 min interview
Skills tested:
Explaining why a single growing state array re-renders every rowUsing React.memo with stable keys and stable object referencesSeparating the streaming message from the finished historyBatching chunks with requestAnimationFrame or a throttleMeasuring the fix with React DevTools Profiler instead of guessing

Advertisement

🧩 Scenario

Your team shipped an AI chat. With short conversations it feels fine, but after 40 or 50 messages typing lags and the fan spins while an answer streams. The profiler shows every message bubble rendering on every chunk. The interviewer asks how you would fix it.

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:

  1. Too many updates. A model can send many small chunks per second. You do not need to render more often than the screen refreshes.
  2. 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

ApproachBenefitCost
React.memo on rowsFinished rows skip renderingBreaks silently if props get new references
Separate streaming stateVery clear isolationOne extra step to move text into history at the end
requestAnimationFrame batchingCaps renders per frameSmall custom hook to maintain and clean up
Library throttle optionOne line of configTied to that library's update model
VirtualizationCheap DOM for huge historiesHarder 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.memo depends 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

  1. How would you handle this if the state lives in Redux or Zustand instead of useState?
  2. What changes if each finished message renders expensive markdown and syntax highlighting?
  3. How would virtualization interact with auto-scroll while an answer streams?
  4. When would useDeferredValue help here, and when would it not?
  5. 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 setTimeout but 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 requestAnimationFrame and 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.

Frequently Asked Questions

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.

Namaste Dev
Sponsored

Cohort-style courses to go from tutorials to job-ready.

Explore Courses

Advertisement