Topic 4.1
The Message Model
In one line
A chat message is a small record: a unique id, a role, its content, a stage (waiting, streaming, done, failed, stopped) and a time. The id lets code and React find the right message while its text is still growing; the record you store is not the same as the shape you send to the model.
Think of it like this
Order tickets in a busy kitchen. Every ticket has a number, a table, the dishes and a stage: *taken*, *cooking*, *served*. The cook can add a dish to ticket 17 while the waiter reads out ticket 18, because the number says which is which. If tickets had no numbers, only the words on them, two tables that ordered the same dish would get mixed up. Every chat message needs a ticket number, and a stage.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Message
- One entry in a chat: who said it, what they said, and which stage it is in.
- Id
- A name for a message that is unique and never changes, so code can point at it.
- Role
- Who the message is from:
user,assistantorsystem(andtoolin Phase 8). - Status
- The stage of a message: pending, streaming, done, error, stopped or empty.
- Placeholder
- An empty assistant message created at send time, before any text arrives.
- React key
- The value React uses to tell list items apart between renders. It must be stable and unique: the id.
- Mount
- React creating a component from scratch. Remounting on every token wastes work and loses local state.
- Parts
- The AI SDK's way to hold a message: a list of text, file and tool pieces instead of one string.
- System message
- Instructions from you to the model (tone, rules). Normally added on your server, not by the browser.
Step by step
01The record
Five fields are enough to start. Everything in the rest of the phase reads or changes one of them.
{
id: "m4", // never changes, unique in the chat
role: "assistant", // "user" | "assistant" | "system"
content: "ORD-1042 has shipped.",
status: "done", // "pending" | "streaming" | "done" | "error" | "stopped" | "empty"
createdAt: 1767225600000, // milliseconds since 1970, for display and ordering
}02Create the placeholder when the user sends
On send, make two messages: the user's, already done, and an empty assistant message with status pending. From then on every token names the assistant message by id.
The first live example builds a four-message chat this way. Read the console: the placeholder has an id and a status but no text yet.
03Convert before sending
toApi() in the first example is the conversion. It keeps only messages with status done, drops empty ones, and returns plain { role, content } objects. When the last answer fails halfway, it stays on the screen but the next request is one message shorter. The model is never shown a sentence it didn't finish.
04Why the key must be the id
The second live example streams one answer into two lists. One list uses the id as the key, the other uses the text. The code counts how many times each row is created from scratch ("mounted").
The row keyed by id is created once. The row keyed by text is created again on every token: six tokens, six extra mounts, and in a real app six times the lost scroll position and text selection.
05Words, emoji and empty answers
The last line of the first example sends Hindi text and an emoji through toApi(): it comes out unchanged. Text is a string like any other. Empty content is the opposite edge: a message with content: "" is skipped when sending and shown as "(no answer)" on screen.
Try it yourself
- 1
See the cut-off answer leave the request
In the first example, change the failed answer's status from
"error"to"done"and run it.The line "after a failure" now reports 4 messages: the half sentence is sent back to the model as if it had said it. The status field is what prevents that.
- 2
Key by index
In the second example change the first list to
key={i}(addias the second argument ofmap). Both counts look fine, because nothing is inserted above the streaming row.Now think about a chat that adds a message at the top (an "Earlier messages" button that loads older ones). Every index shifts and every row below remounts. The index only looks safe while rows never move.
- 3
Send an extra field
In the first example, change
toApito.map((m) => m)so it returns the stored records unchanged, and look at the "sent to the model" line.The request now carries ids, statuses and timestamps. The stand-in model in this course ignores them. A strict provider may answer
400for unknown fields, and every request is bigger than it needs to be.
Code & diagrams
Nothing here touches the network: it is the data model on its own. Notice the placeholder has an id before it has any text.
Preview
Console
Nothing logged yet.Expected console output
{ id: 'm1', role: 'user', content: 'Where is ORD-1042?', status: 'done', createdAt: 1005 }
placeholder: m4 streaming ""
sent to the model: [ { role: 'user', content: 'Where is ORD-1042?' }, { role: 'assistant', content: 'ORD-1042 has shipped.' }, { role: 'user', content: 'Thanks!' } ]
after a failure, sent: 3 messages (the cut-off answer is left out)
[ { role: 'user', content: 'चाय मिलेगी? ☕' } ]Two lists show the same streaming answer. Only the key differs. A mount that happens again and again is invisible on the page, which is why you count it.
Preview
Console
Nothing logged yet.Expected console output
mounts at start: {"byText":2,"byId":2}
mounts while streaming: {"byText":8,"byId":2}Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Key the message by its text
In the second example, change key={m.id} kind="byId" to key={m.text} kind="byId".
Break #2
Forget to filter by status
In the first example, delete the line .filter((m) => m.status === "done") // leave out failed and unfinished answers.
Myth vs fact
Myth
The message is just a string; ids are over-engineering.
Fact
The moment two messages exist and one is still growing, code must say which one to update. Without an id you update "the last one", and that breaks as soon as the user switches chats or sends twice (Topic 4.7).
Myth
Using the array index as the React key is fine for a chat.
Fact
It works only while rows never move. Insert, delete or trim one message and every row after it gets the wrong key, which can attach local state to the wrong message.
Myth
What you store is what you send.
Fact
The stored record has your own fields and unfinished messages. The request is a separate, cleaned-up view of it, built fresh on every send.
Myth
crypto.randomUUID() works everywhere.
Fact
It needs a secure context (https or localhost). On a plain http page it is missing and throws, so a deployment mistake becomes a broken chat.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Keep
createdAtfor display and for ordering messages that arrive from two places, but never use it as an id. Two messages made in the same millisecond share a time, and a clock that jumps backwards (the user changes the time) reorders history. - ▸
If you later store chats on a server, let the server own the final ids, and keep a temporary client id for the optimistic message until the server answers. Show one id in the UI at all times by mapping the temporary one to the real one in your state.
- ▸
Treat
statusas an enum with a closed list. A new value ("retrying") is a deliberate change that touches every place that switches on it. A free-text status like "almost done" makes impossible combinations easy to write. - ▸
Do not put derived data in the record. "Is this the last message?" and "is the chat busy?" can be computed from the list when needed; stored copies go out of date.
Remember this
- 1
The problem this solves: So far an answer was one string in one variable. A real chat has many messages. Some are finished, one is still growing, one failed halfway, one was stopped by the user. Your code must say "add this word to *that* message" and "show *that* one as failed". A bare string can't be pointed at, and it doesn't know what stage it is in. This topic gives you the record that fixes both.
- 2
The fields.
id: a unique name for the message that never changes.role: who wrote it.content: the text.status: the stage.createdAt: when it was made, as a number (milliseconds since 1970). Real models use three roles:user(the person),assistant(the model) andsystem(instructions from you, normally added on the server, Topic 1.5). Phase 8 adds a fourth,tool. - 3
Status is not decoration. An assistant message is created the moment the user sends a question, before any text exists, as an empty placeholder with status
pending("Mitra is thinking…"). The first token makes itstreaming. The end makes itdone. Failure makes iterror, a user stop makes itstopped, and a stream that ended with no text at all can be markedempty. Because the stage lives on the message, the screen can show each message in its own way, and a chat can hold several different stages at once. - 4
Why ids matter, part one: finding the message. When a token arrives, the code updates "the message with id
a3", not "the last message" and not "message number 5". Positions change (a message is added, an old one is removed, the user switches chats). An id doesn't. This is also what keeps a late token from landing in the wrong place (Topic 4.7). - 5
Why ids matter, part two: React keys. React uses the
keyof each list item to decide whether it is the *same* item as last time (keep it, update it) or a *new* one (throw the old one away and build it again). If the key is the text, then every new token changes the key, and React destroys and rebuilds the message on every token: scroll position, text selection and animations inside it are lost. If the key is the index, inserting a message at the top shifts every key. The key must be the id, and the id must exist from the placeholder on, so it never changes while the text grows. - 6
Ids, in practice. Make them when the message is created. In the browser use
crypto.randomUUID()(it needs a secure page:https, orlocalhost). A counter (m1,m2…) is fine for one page, but after a reload it starts again and collides with saved messages, so a saved chat needs ids that stay unique (Topic 4.4). The examples here use a counter so their output repeats; it is the only reason. - 7
What you store and what you send are different. Your record has
id,statusandcreatedAt. A model API wants onlyroleandcontent, and some providers reject fields they don't know. So before every request you convert: keep only the messages worth sending (leave out failed, unfinished and empty ones, or the model is told it said half a sentence) and keep only the two fields. The Vercel AI SDK (Phase 5) uses the same idea with a richer record: a message holds a list ofparts(text, files, tool calls) instead of one string. A singlecontentstring is the simple case of that. - 8
Edge cases. An assistant message can be empty (the model sent nothing, or only ended): show "(no answer)" instead of a blank bubble. Text is Unicode: Hindi, emoji and mixed scripts pass through unchanged as long as you never cut a string at a fixed number of bytes (Topic 4.5 shows the one place you do cut). A very long answer is one message with a very large string: store it as is, and Topic 6.5 shows how to keep rendering it cheap.
Explain it without notes
What fields does a chat message need, and why?
Why must the React key of a streaming message be its id and not its text?
What is the difference between the message you store and the message you send to a model API?
Why create the assistant message before the first token arrives?
Practice
Write lastUserText(messages) that returns the content of the newest user message, or an empty string if there is none.
Write describe(message) that returns the text shown on screen for a message: "Mitra is thinking…" for pending, "(no answer)" for empty or an error message with no text, and otherwise the content.
Trade-offs
- ↔
A single
contentstring is simple and enough for text chat. The AI SDK'spartslist costs more to learn and pays off when a message mixes text with tool calls, files and sources (Phases 5, 7 and 8). Start with the string only if you are sure you won't need those. - ↔
Storing
statuson each message adds a field to maintain but makes the screen and the request builder simple and correct. Deriving it from "is this the last message and is the chat busy?" is less to store and breaks as soon as two messages are in different stages.
Done when you can
I can list the fields of a message and say why each exists.
I can create an assistant placeholder at send time and name its stages.
I can explain why a React key must be a stable id, not text or index.
I can convert stored messages to the shape a model API expects, leaving out failed and empty ones.
I can show an empty or failed message in a way that doesn't mislead.