Skip to main content
Saved
Pattern
Difficulty Intermediate

Message Thread

Model a conversation as an ordered list of role-tagged messages, each carrying its own status, so streaming and errors stay scoped to one turn.

Den Odell
By Den Odell Added

Message Thread

Problem

The first version of a chat UI usually stores the conversation as two strings: the user’s last prompt and the assistant’s last reply. It works right up until the second message. Then you need history, and the shape falls apart.

The deeper trap is putting isStreaming or error in one place at the top of the component. Now regenerating the third message flips a flag that the fifth message also reads, and suddenly two bubbles show a spinner. A failure on one turn paints an error across the transcript. The states this kind of interface is all about (thinking, streaming, stopped, failed) have to live somewhere, and if that somewhere isn’t the individual message, they leak into the ones next to it.

A conversation is a list of turns, each authored by someone, each in some state of completion. Modeling it as anything less makes every later feature a fight.

Solution

Store the conversation as an ordered array of message objects. Each message has a stable id (generate it on creation, don’t use the array index), a role of user, assistant, or system, its content, and a status such as streaming, complete, stopped, or error. Sending a prompt pushes a user message plus an empty assistant message in streaming status; the Streaming Response loop appends tokens to that specific assistant message by id; finishing flips its status.

Because state is per-message, everything else scopes correctly. A Thinking Indicator renders for the one message that’s streaming with empty content. Response Regeneration replaces a single message without touching its neighbors. An error marks one turn, not the page.

Render each role with its own layout, key the list by message id so the framework reuses DOM nodes as content grows, and mark the container as an ARIA log so assistive tech announces new turns. For very long conversations, reach for Virtual Scrolling, but only once you actually have hundreds of messages, since virtualization complicates the auto-scroll behavior streaming needs.

Example

The message model and reducer, the rendered transcript across frameworks, per-message status rendering, and the accessibility wiring.

The Conversation Model

A small reducer keeps the mutations (append a turn, stream into it, finalize it) in one place.

function conversationReducer(messages, action) {
  switch (action.type) {
    case 'send':
      return [
        ...messages,
        { id: crypto.randomUUID(), role: 'user', content: action.text, status: 'complete' },
        { id: action.replyId, role: 'assistant', content: '', status: 'streaming' },
      ];

    case 'token': // append a streamed chunk to one assistant message
      return messages.map(m =>
        m.id === action.id ? { ...m, content: m.content + action.chunk } : m
      );

    case 'finish': // 'complete' | 'stopped' | 'error'
      return messages.map(m =>
        m.id === action.id ? { ...m, status: action.status } : m
      );

    default:
      return messages;
  }
}

Rendering the Transcript

function MessageThread({ messages }) {
  return (
    <div className="thread" role="log" aria-live="polite" aria-label="Conversation">
      {messages.map(m => (
        <article key={m.id} className={`message message--${m.role}`} data-status={m.status}>
          <span className="role">{m.role === 'user' ? 'You' : 'Assistant'}</span>
          <div className="content">
            {m.content || (m.status === 'streaming' && <ThinkingIndicator />)}
          </div>
          {m.status === 'error' && <RetryButton id={m.id} />}
        </article>
      ))}
    </div>
  );
}

Distinguishing Turns Visually and Semantically

Roles differ in more than alignment, so give each a heading structure that a screen reader can navigate rather than read as one flat block.

.message { display: grid; gap: 0.25rem; padding: 0.75rem 1rem; }
.message--user { justify-items: end; }
.message--user .content { background: var(--accent); color: #fff; border-radius: 1rem 1rem 0 1rem; }
.message--assistant .content { background: var(--surface); border-radius: 1rem 1rem 1rem 0; }
.message[data-status="error"] { outline: 1px solid var(--danger); }
.message[data-status="stopped"] .content::after { content: " (stopped)"; opacity: 0.6; }

Announcing New Turns Once

An aria-live="polite" log announces new messages, but streaming into it token by token would spam a screen reader with fragments. Announce the completed message, and mark the streaming region as aria-atomic="false" so only appended text is read, or defer the announcement to the finish action.

function AssistantMessage({ message }) {
  const done = message.status !== 'streaming';
  return (
    <div
      className="content"
      // Only announce once the turn is complete, not on every token
      aria-live={done ? 'polite' : 'off'}
    >
      {message.content}
    </div>
  );
}

Benefits

  • Streaming, stopping, and errors scope to a single turn, so activity in one message never bleeds into another.
  • Stable ids let the framework reuse DOM as a message grows, which keeps streaming smooth and preserves focus and selection.
  • History, editing, branching, and regeneration all become straightforward list operations rather than special cases.
  • The log role and per-message live regions make the conversation navigable and announced for assistive tech.
  • A normalized array is trivial to persist, replay from a fixture, and drive tests against.

Tradeoffs

  • Keeping the array as the single source of truth requires discipline, because it’s tempting to reach for a stray top-level isLoading flag and reintroduce the leak.
  • Per-message live regions need care, or a screen reader either misses new turns or gets flooded with token fragments.
  • Very long threads eventually need virtualization, which complicates the stick-to-bottom scrolling that streaming depends on.
  • Rich content inside messages (markdown, code, tool calls, images) means each bubble is its own rendering problem rather than a plain string.
  • Generating and threading stable ids adds a little ceremony compared to just concatenating strings.

Summary

A conversation is an ordered list of role-tagged messages, each with a stable id and its own status. Model it that way and streaming appends to one turn, errors mark one turn, and regeneration replaces one turn, while a log role keeps the whole transcript accessible. It’s more work upfront than two strings, but it’s what every other feature in a chat UI ends up building on.

Newsletter

A Monthly Email
from Den Odell

Behind-the-scenes thinking on frontend patterns, site updates, and more

No spam. Unsubscribe anytime.