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>
);
} <template>
<div class="thread" role="log" aria-live="polite" aria-label="Conversation">
<article
v-for="m in messages"
:key="m.id"
:class="`message message--${m.role}`"
:data-status="m.status"
>
<span class="role">{{ m.role === 'user' ? 'You' : 'Assistant' }}</span>
<div class="content">
<template v-if="m.content">{{ m.content }}</template>
<ThinkingIndicator v-else-if="m.status === 'streaming'" />
</div>
<RetryButton v-if="m.status === 'error'" :id="m.id" />
</article>
</div>
</template>
<script setup>
defineProps({ messages: Array });
</script> <script>
export let messages = [];
</script>
<div class="thread" role="log" aria-live="polite" aria-label="Conversation">
{#each messages as m (m.id)}
<article class="message message--{m.role}" data-status={m.status}>
<span class="role">{m.role === 'user' ? 'You' : 'Assistant'}</span>
<div class="content">
{#if m.content}{m.content}
{:else if m.status === 'streaming'}<ThinkingIndicator />{/if}
</div>
{#if m.status === 'error'}<RetryButton id={m.id} />{/if}
</article>
{/each}
</div> class MessageThread extends HTMLElement {
set messages(list) {
this.setAttribute('role', 'log');
this.setAttribute('aria-live', 'polite');
this.innerHTML = list.map(m => `
<article class="message message--${m.role}" data-status="${m.status}">
<span class="role">${m.role === 'user' ? 'You' : 'Assistant'}</span>
<div class="content">${m.content || (m.status === 'streaming' ? '…' : '')}</div>
</article>
`).join('');
}
}
customElements.define('message-thread', MessageThread); 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
logrole 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
isLoadingflag 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.