Repository navigation
09. Message Tree & Branching
Traditional chat interfaces model a conversation as a linear sequence: message 1, message 2, message 3, and so on. This works until a user wants to rephrase a question, retry a response, or explore an alternative line of reasoning. In a linear model, any of these actions either overwrites history or requires the user to start a new conversation entirely.
This application uses a tree data structure to represent conversations. Every message is a node. Every node has exactly one parent (except the root) and zero or more children. When a user edits a message, the original remains untouched — a new sibling node is created alongside it. When the AI regenerates a response, a new branch grows from the point of divergence.
The design draws directly from version control systems. Just as Git creates branches rather than overwriting commits, this system creates conversation branches rather than overwriting messages. The user may navigate freely between branches, revisiting abandoned lines of thought or comparing alternative responses side by side.
Three properties make this possible:
| Property | Stored In | Purpose |
|---|---|---|
parentId |
Database (per message) | Links each message to its predecessor |
childrenIds |
Application memory only | Lists all direct replies to a message |
currentLeafId |
Database (per chat) | Identifies which branch the user is currently viewing |
The tree is encoded using two entities: the chat record and the message node. Their relationship is intentionally minimal at the database level — the richer structure emerges at the application layer.
Each message node carries the following fields:
| Field | Type | Source | Purpose |
|---|---|---|---|
id |
String | Database | Unique identifier for the node |
role |
Enum | Database | One of user, assistant, or system
|
content |
String | Database | The message text |
parentId |
String (nullable) | Database | ID of the parent node; null for root messages |
childrenIds |
String array | Computed at runtime | IDs of all direct child nodes |
metadata |
JSON string | Database | Model ID, tool calls, reasoning tokens, selected tools |
createdAt |
Timestamp | Database | Creation time of the node |
Each chat record holds:
| Field | Type | Purpose |
|---|---|---|
id |
String | Unique chat identifier |
title |
String | Display name of the conversation |
currentLeafId |
String (nullable) | Points to the deepest message in the active branch |
messages |
Hash map | All messages in the conversation, keyed by ID |
Messages are stored in a flat hash map — a dictionary keyed by message ID, not a nested object hierarchy. This provides O(1) lookup for any message by ID and integrates cleanly with immutable state management patterns.
The tree topology is encoded entirely through adjacency-list links: each node's parentId points upward, and each node's childrenIds points downward. There is no nesting.
The parentId field carries no database-level foreign key constraint. This is a deliberate choice. Self-referential foreign keys introduce cascade complexity — deleting a subtree would require careful ordering or deferred constraints. Instead, tree integrity is enforced entirely at the application layer, where the full tree is available in memory and traversal logic can guarantee consistency.
The childrenIds field does not exist in the database at all. It is reconstructed during hydration by scanning all messages and building parent-to-children mappings. This avoids denormalisation and the synchronisation burden that comes with it.
erDiagram
CHAT {
string id PK
string title
string currentLeafId FK
timestamp createdAt
timestamp updatedAt
}
MESSAGE {
string id PK
string chatId FK
string role
string content
string parentId
string metadata
timestamp createdAt
}
CHAT ||--o{ MESSAGE : contains
CHAT ||--o| MESSAGE : "currentLeafId points to"
MESSAGE ||--o| MESSAGE : "parentId references"
A conversation tree grows downward from a root node. Each level alternates between user and assistant roles (with an optional system prompt at the root). When a user edits a message or requests regeneration, a new branch sprouts from the point of divergence.
The simplest case: a single path from root to leaf, with no edits or regenerations.
graph TD
M1["🧑 User: What is photosynthesis?"]
M2["🤖 Assistant: Photosynthesis is the process by which plants..."]
M3["🧑 User: Tell me more about chlorophyll"]
M4["🤖 Assistant: Chlorophyll is a green pigment..."]
M1 --> M2
M2 --> M3
M3 --> M4
style M4 fill:#4ade80,stroke:#16a34a,color:#000
The green node is the currentLeafId — the tip of the active branch.
When the user edits "Tell me more about chlorophyll" to ask about something different, a sibling node is created. Each sibling may spawn its own subtree.
graph TD
M1["🧑 User: What is photosynthesis?"]
M2["🤖 Assistant: Photosynthesis is the process..."]
M3["🧑 User: Tell me more about chlorophyll"]
M4["🤖 Assistant: Chlorophyll is a green pigment..."]
M5["🧑 User: What about cellular respiration?"]
M6["🤖 Assistant: Cellular respiration is..."]
M7["🧑 User: How does the Krebs cycle work?"]
M8["🤖 Assistant: The Krebs cycle involves..."]
M1 --> M2
M2 --> M3
M2 --> M5
M3 --> M4
M5 --> M6
M6 --> M7
M7 --> M8
style M3 fill:#94a3b8,stroke:#64748b,color:#000
style M4 fill:#94a3b8,stroke:#64748b,color:#000
style M5 fill:#60a5fa,stroke:#2563eb,color:#000
style M6 fill:#60a5fa,stroke:#2563eb,color:#000
style M7 fill:#60a5fa,stroke:#2563eb,color:#000
style M8 fill:#4ade80,stroke:#16a34a,color:#000
| Colour | Meaning |
|---|---|
| Default (no highlight) | Shared ancestor nodes |
| Blue | Active branch (currently viewed) |
| Grey | Inactive branch (preserved but not displayed) |
| Green |
currentLeafId — the terminal node of the active branch |
The user interface renders only the active branch — a single linear path extracted from the tree. The full tree exists in memory, but the user sees one conversation thread at a time. Branch navigation controls allow switching between sibling branches.
graph TD
subgraph "Full Tree (in memory)"
R["Root"]
A["Branch A — Message"]
B["Branch B — Message"]
A1["A child 1"]
A2["A child 2"]
B1["B child 1"]
B2["B child 2"]
B3["B child 3"]
R --> A
R --> B
A --> A1
A --> A2
B --> B1
B1 --> B2
B2 --> B3
end
subgraph "Rendered Thread (active branch)"
R2["Root"] --> Br["Branch B — Message"] --> B1r["B child 1"] --> B2r["B child 2"] --> B3r["B child 3"]
end
style B3 fill:#4ade80,stroke:#16a34a,color:#000
style B3r fill:#4ade80,stroke:#16a34a,color:#000
The database stores messages as flat rows — each row contains an id, parentId, and content fields, but no childrenIds. The application must reconstruct the tree topology every time messages are loaded. This process is called hydration.
Hydration proceeds in exactly two passes:
| Pass | Action | Output |
|---|---|---|
| Pass 1 | Create message objects from database rows; initialise each with an empty childrenIds array |
A hash map of message nodes with no parent-child links |
| Pass 2 | Iterate every message; for each with a non-null parentId, append its ID to the parent's childrenIds
|
A fully linked tree with bidirectional traversal |
This runs on both the server (during initial page render for a single chat) and the client (during bulk hydration when all chats are loaded into the global store).
flowchart TD
A["Database rows fetched"] --> B["Pass 1: Create message objects"]
B --> C["Each message gets empty childrenIds array"]
C --> D["Messages stored in flat hash map by ID"]
D --> E["Pass 2: Iterate all messages"]
E --> F{"Has parentId?"}
F -- Yes --> G["Push message ID into parent's childrenIds"]
F -- No --> H["Mark as root node"]
G --> I["Fully linked tree ready"]
H --> I
style A fill:#e0e7ff,stroke:#6366f1,color:#000
style I fill:#4ade80,stroke:#16a34a,color:#000
Storing childrenIds in the database would introduce a denormalisation problem. Every insert or delete would require updating two records (the new message and its parent), creating a synchronisation burden and a window for inconsistency. By computing childrenIds at runtime, the system maintains a single source of truth (parentId) and derives the reverse mapping on demand.
Four pure functions power all tree manipulations. Each operates on the flat hash map and returns either a modified copy or a read-only result. None mutates the original data structure in place.
| Operation | Purpose | Traversal Direction | Mutability |
|---|---|---|---|
| Insert Message | Adds a node and updates the parent's children list | — | Immutable (returns new hash map) |
| Remove Subtree | Recursively deletes a node and all descendants | Downward (depth-first) | Shallow copy, then mutates copy |
| Reconstruct Thread | Walks from leaf to root to extract the active branch | Upward (parent chain) | Read-only |
| Get Deepest Leaf | Descends from a node following last children to the terminal | Downward (last-child) | Read-only |
Insertion adds a new message node to the tree and, if the message has a parent, registers it as a child of that parent.
The operation follows an immutable update pattern:
- A shallow copy of the entire message hash map is created.
- The new message is added to the copy.
- If the new message has a
parentId, the parent node is also shallow-copied, and the new message's ID is appended to the parent'schildrenIds. - The modified copy is returned; the original hash map is untouched.
This immutable approach is essential for the state management layer, which relies on reference equality checks to determine whether re-renders are necessary.
graph TD
subgraph "Before Insert"
P1["Parent node<br/>childrenIds: [C1]"]
C1["Child 1"]
P1 --> C1
end
subgraph "After Insert"
P2["Parent node (copy)<br/>childrenIds: [C1, C2]"]
C1b["Child 1"]
C2["Child 2 (new)"]
P2 --> C1b
P2 --> C2
end
style C2 fill:#4ade80,stroke:#16a34a,color:#000
style P2 fill:#fbbf24,stroke:#d97706,color:#000
The yellow node indicates the shallow-copied parent; the green node is the newly inserted message.
Subtree removal deletes a target node and every descendant beneath it. The operation proceeds in three steps:
- The target node's ID is removed from its parent's
childrenIds. - A depth-first traversal collects the target and all its descendants.
- Every collected node is deleted from the hash map.
The function returns the parentId of the deleted root, enabling callers to compute a new currentLeafId if the active branch was affected.
graph TD
subgraph "Before Removal"
R["Root"]
A["Node A"]
B["Node B (target)"]
B1["B → Child 1"]
B2["B → Child 2"]
B2a["B2 → Grandchild"]
R --> A
R --> B
B --> B1
B --> B2
B2 --> B2a
end
subgraph "After Removal"
R2["Root"]
A2["Node A"]
R2 --> A2
end
style B fill:#f87171,stroke:#dc2626,color:#000
style B1 fill:#f87171,stroke:#dc2626,color:#000
style B2 fill:#f87171,stroke:#dc2626,color:#000
style B2a fill:#f87171,stroke:#dc2626,color:#000
Red nodes are removed. The entire subtree rooted at Node B is deleted, including all descendants regardless of depth.
Thread reconstruction extracts the active conversation branch from the tree. Starting from the currentLeafId, it walks upward via the parentId chain, prepending each ancestor until it reaches a root node (one with no parent).
The result is a linear, root-to-leaf ordered array — the sequence of messages the user sees in the chat interface.
graph TD
M1["🧑 User: Question A"]
M2["🤖 Assistant: Answer A"]
M3["🧑 User: Follow-up B"]
M4["🤖 Assistant: Answer B"]
M5["🧑 User: Follow-up C (edit)"]
M6["🤖 Assistant: Answer C"]
M7["🧑 User: Follow-up D"]
M8["🤖 Assistant: Answer D"]
M1 --> M2
M2 --> M3
M2 --> M5
M3 --> M4
M5 --> M6
M6 --> M7
M7 --> M8
style M1 fill:#60a5fa,stroke:#2563eb,color:#000
style M2 fill:#60a5fa,stroke:#2563eb,color:#000
style M5 fill:#60a5fa,stroke:#2563eb,color:#000
style M6 fill:#60a5fa,stroke:#2563eb,color:#000
style M7 fill:#60a5fa,stroke:#2563eb,color:#000
style M8 fill:#4ade80,stroke:#16a34a,color:#000
style M3 fill:#94a3b8,stroke:#64748b,color:#000
style M4 fill:#94a3b8,stroke:#64748b,color:#000
The blue path is the reconstructed thread: M1 → M2 → M5 → M6 → M7 → M8. Grey nodes exist in the tree but are not part of the active branch.
The walk direction is upward (leaf to root), but the output is reversed to produce a root-to-leaf sequence for rendering.
Starting from any node, this operation descends by always selecting the last element in childrenIds at each level. It continues until it reaches a terminal node — one with no children.
This is used primarily during branch switching. When the user selects a sibling node, the system must determine where the tip of that sibling's subtree lies. The deepest leaf function provides that answer.
graph TD
N1["Start node"]
N2["Child A"]
N3["Child B (last)"]
N4["B → Child X"]
N5["B → Child Y (last)"]
N6["Y → Child Z (last, terminal)"]
N1 --> N2
N1 --> N3
N3 --> N4
N3 --> N5
N5 --> N6
style N1 fill:#fbbf24,stroke:#d97706,color:#000
style N3 fill:#a78bfa,stroke:#7c3aed,color:#000
style N5 fill:#a78bfa,stroke:#7c3aed,color:#000
style N6 fill:#4ade80,stroke:#16a34a,color:#000
| Colour | Meaning |
|---|---|
| Yellow | Starting node |
| Purple | Traversal path (last child at each level) |
| Green | Deepest leaf (returned result) |
The "last child" heuristic ensures the user lands on the most recently created branch — typically the most relevant continuation.
Two user actions cause the tree to branch: editing a message and regenerating a response. Both follow the same principle — new nodes are created as siblings rather than replacements — but they differ in where the branching occurs.
When a user edits a previously sent message, the original message is not modified. Instead, the system creates a new message node with the edited content, placed as a sibling of the original — that is, sharing the same parent.
The flow proceeds as follows:
- The user submits an edit to an existing message.
- A new message node is created with the edited content and the same
parentIdas the original. - The parent's
childrenIdsgrows to include the new sibling. - The AI generates a response, which becomes a child of the new edited message.
-
currentLeafIdadvances to the AI response, switching the view to the new branch. - The original message and its entire downstream subtree remain intact and navigable.
graph TD
subgraph "Before Edit"
P["🧑 User: What is X?"]
A["🤖 Assistant: X is..."]
F["🧑 User: Tell me more"]
A2["🤖 Assistant: More about X..."]
P --> A
A --> F
F --> A2
end
subgraph "After Edit"
P2["🧑 User: What is X?"]
A3["🤖 Assistant: X is..."]
F2["🧑 User: Tell me more"]
A4["🤖 Assistant: More about X..."]
E["🧑 User: What about Y instead? (edit)"]
A5["🤖 Assistant: Y is..."]
P2 --> A3
A3 --> F2
A3 --> E
F2 --> A4
E --> A5
end
style E fill:#60a5fa,stroke:#2563eb,color:#000
style A5 fill:#4ade80,stroke:#16a34a,color:#000
style F2 fill:#94a3b8,stroke:#64748b,color:#000
style A4 fill:#94a3b8,stroke:#64748b,color:#000
The edited message (blue) becomes a sibling of the original follow-up. The original branch (grey) is preserved but no longer active.
Regeneration differs from editing in one important respect: it creates a branch at the grandparent level rather than the parent level. When a user requests a fresh AI response, the system does not simply create a new assistant message as a sibling of the existing one. Instead, it creates a new user message (with identical content to the original) as a sibling of the original user message, and then generates a fresh assistant response as its child.
This maintains a critical invariant: every assistant message has exactly one user message parent. The role alternation pattern (user → assistant → user → assistant) is never violated.
graph TD
subgraph "Before Regeneration"
U1["🧑 User: Explain quantum entanglement"]
A1["🤖 Assistant: Quantum entanglement is..."]
U1 --> A1
end
subgraph "After Regeneration"
U1b["🧑 User: Explain quantum entanglement"]
A1b["🤖 Assistant: Quantum entanglement is... (original)"]
U2["🧑 User: Explain quantum entanglement (copy)"]
A2["🤖 Assistant: Entanglement refers to... (regenerated)"]
U1b --> A1b
U1b --> U2
U2 --> A2
end
style A1b fill:#94a3b8,stroke:#64748b,color:#000
style U2 fill:#60a5fa,stroke:#2563eb,color:#000
style A2 fill:#4ade80,stroke:#16a34a,color:#000
Design note: This approach ensures that tree traversal logic never encounters consecutive same-role nodes, and that every branch maintains a predictable user→assistant rhythm regardless of depth.
When a message has siblings — that is, when its parent has multiple children — the user interface displays navigation controls alongside that message. These controls allow the user to switch between alternative versions of the conversation from that point onward.
The controls present three elements:
| Element | Display | Action |
|---|---|---|
| Left arrow | ← |
Switch to the previous sibling |
| Position counter | 2 / 3 |
Shows current sibling index and total sibling count |
| Right arrow | → |
Switch to the next sibling |
Siblings are determined by inspecting the parent node's childrenIds array. The current message's position within that array dictates the counter display. Root messages (those with no parent) are treated as siblings of each other — they share a virtual root.
sequenceDiagram
actor User
participant UI as Chat Interface
participant Store as State Store
participant Tree as Tree Operations
participant DB as Database
User->>UI: Clicks sibling arrow (→)
UI->>Store: Dispatch branch switch
Store->>Tree: Identify selected sibling ID
Tree->>Tree: getDeepestLeaf(siblingId)
Tree-->>Store: Returns deepest leaf ID
Store->>Store: Set currentLeafId (optimistic)
Store->>Store: reconstructThread(newLeafId)
Store-->>UI: Re-render with new branch
Store->>DB: Persist currentLeafId
DB-->>Store: Confirmation (or swallowed error)
The key detail: selecting a sibling does not simply display that sibling's message. It descends to the deepest leaf of the sibling's entire subtree, then reconstructs the full thread from root to that leaf. This ensures the user always sees a complete conversation, not a truncated fragment.
The currentLeafId field on the chat record is the single source of truth for which branch is currently active. It always points to the deepest message in the viewed branch. Every other piece of branch state — the rendered thread, the sibling counters, the navigation arrows — is derived from this one value.
currentLeafId changes in response to four events:
stateDiagram-v2
[*] --> Empty: Chat created (no messages)
Empty --> Leaf1: First message sent
Leaf1 --> Leaf2: New message appended
Leaf2 --> Leaf3: New message appended
Leaf3 --> LeafN: Continued conversation
LeafN --> BranchLeaf: Branch switched via sibling navigation
BranchLeaf --> LeafN: Branch switched back
LeafN --> EditLeaf: Message edited → new branch created
EditLeaf --> NewLeaf: AI response committed
LeafN --> RecomputedLeaf: Subtree deleted → recomputed from parent
| Event |
currentLeafId Behaviour |
|---|---|
| New message appended | Advances to the newly created message |
| Branch switched | Jumps to the deepest leaf of the selected sibling's subtree |
| Message edited | Advances to the AI response on the new branch |
| Subtree deleted | Recomputed: ascend to deleted node's parent, then descend to its deepest remaining leaf |
| AI response streamed | Advances to the committed assistant message once streaming completes |
When a subtree is deleted and that subtree contained the currentLeafId, the system must find a new leaf:
- The deleted node's
parentIdis retrieved (returned by the removal operation). - From the parent,
getDeepestLeaf()descends to the terminal node of the parent's remaining subtree. -
currentLeafIdis updated to this new terminal node. - The thread is reconstructed from the new leaf.
If the parent has no remaining children, the parent itself becomes the currentLeafId.
During AI response generation, the streaming system interacts carefully with the tree to avoid inconsistent states. Communication between client and server uses Server-Sent Events (SSE) — a unidirectional protocol where the server pushes events over a long-lived HTTP connection.
sequenceDiagram
actor User
participant UI as Chat Interface
participant Store as State Store
participant API as Chat API
participant Tree as Tree Operations
participant DB as Database
User->>UI: Sends message
UI->>Store: Insert user message (optimistic)
Store->>Tree: insertMessage(userMsg)
Store->>DB: Persist user message
UI->>API: POST /api/chat (SSE stream)
Note over UI: Placeholder node rendered<br/>with synthetic ID
loop SSE Events
API-->>UI: text / reasoning / tool-call events
UI->>Store: Update placeholder content
end
API-->>UI: "done" event (with server message ID)
UI->>Store: Replace placeholder with committed node
Store->>Tree: insertMessage(assistantMsg)
Store->>Store: Set currentLeafId → assistantMsg.id
Store->>DB: Persist assistant message
Store->>DB: Update currentLeafId
| Phase | Tree State | Placeholder Behaviour |
|---|---|---|
| Before stream starts | User message committed to tree | No placeholder exists |
| During streaming | Tree unchanged; user message is the leaf | Placeholder renders below user message; content updates incrementally |
| Stream completes ("done") | Assistant message committed to tree | Placeholder replaced with real node; currentLeafId advances |
| Stream aborted by user | Accumulated partial content committed to tree | Partial-content node inserted to maintain tree consistency |
| Stream error (SSE or network) | Error message committed (SSE errors) or no node committed (network failures) | Tree remains consistent; the user message is the deepest node |
The critical design principle: the streaming system never leaves the tree in an inconsistent state. If the user aborts a stream, accumulated partial content is committed as a valid tree node. If the server returns an error via SSE, the error message text is committed instead of the partial content. In cases of network failure, no additional node is committed — the user message remains the deepest node. In all scenarios, the user may regenerate from the current leaf, creating a new branch.
The placeholder node uses a synthetic ID that is never persisted to the database. It exists only in the rendering layer — React component local state — and is never inserted into the Zustand message tree. When the real message arrives, the placeholder is replaced and the committed node is inserted into the tree atomically.
All tree mutations follow an optimistic update pattern. The user interface reflects changes immediately, without waiting for database confirmation. This eliminates perceived latency and provides a responsive editing experience, even on slow or unreliable network connections.
flowchart TD
A["User triggers action"] --> B["Capture full state snapshot"]
B --> C["Apply mutation immediately (UI updates)"]
C --> D["Execute server action (DB persistence)"]
D --> E{"Server action succeeded?"}
E -- Yes --> F["Discard snapshot; state is consistent"]
E -- No --> G["Restore snapshot (full rollback)"]
G --> H["Display error notification"]
style A fill:#e0e7ff,stroke:#6366f1,color:#000
style C fill:#4ade80,stroke:#16a34a,color:#000
style G fill:#f87171,stroke:#dc2626,color:#000
Not all operations use the same error handling strategy. The system distinguishes between two modes:
| Mode | Behaviour on Failure | Used For |
|---|---|---|
| Strict | Full rollback to snapshot; error notification displayed | Message deletion, message persistence |
| Swallow | Error logged silently; optimistic state retained |
currentLeafId updates (branch switching) |
The rationale for "swallow" mode on branch switches: currentLeafId is a navigational convenience, not a data integrity field. If the server fails to persist a branch switch, the worst consequence is that a page reload returns the user to a different branch. This trade-off enables an offline-first feel for navigation without risking data loss.
Message deletions, by contrast, use strict mode because a failed deletion that appears successful would leave the user believing messages are gone when they are not — a confusing and potentially data-leaking inconsistency.
| Operation | Mode | Rollback Behaviour |
|---|---|---|
| Send message | Strict | Remove optimistically inserted message |
| Delete message | Strict | Restore deleted subtree from snapshot |
| Edit message (create branch) | Strict | Remove new branch; restore original currentLeafId
|
| Switch branch | Swallow | Retain optimistic currentLeafId; log error |
| Rename chat | Strict | Restore original title |
| Commit streamed response | Strict | Remove committed assistant node |
| Decision | Rationale | Trade-off |
|---|---|---|
No database foreign key on parentId
|
Avoids cascade complexity with self-referential trees; simplifies bulk deletion | Application layer must enforce tree integrity; orphaned nodes possible if application logic has bugs |
childrenIds computed at runtime |
Eliminates denormalisation; single source of truth via parentId
|
Two-pass hydration cost on every load; scales linearly with message count |
| Flat hash map storage | O(1) lookup by ID; efficient for immutable state updates; clean Zustand integration | Must reconstruct linear thread for rendering; no inherent ordering |
| Last-child descent for branch switching | Surfaces the most recently created continuation of a branch | Users cannot land on a mid-branch message; must scroll to find the divergence point |
| Regeneration creates user+assistant pair | Maintains consistent parent-child role alternation throughout the tree | Slightly higher storage cost; duplicate user message content across branches |
| Streaming uses placeholder node | Avoids premature tree commits during incomplete generation | Temporary visual inconsistency (synthetic ID visible in dev tools, not to users) |
| Optimistic updates with snapshot rollback | Instant UI feedback; eliminates network-dependent latency for all interactions | Complexity of maintaining rollback snapshots; potential for brief visual inconsistency on rollback |
currentLeafId as sole branch indicator |
Single source of truth; entire branch state derived from one value | Must recompute on every deletion; no history of previously viewed branches |
- OpenAI Platform — Conversation State
- Vercel AI SDK — Chatbot UI
- Vercel AI SDK — Chatbot Message Persistence
- Vercel AI SDK — Generating and Streaming Text
- Mike Hillyer — Managing Hierarchical Data in MySQL
- PostgreSQL Documentation — WITH Queries (Recursive CTEs)
- StackOverflow — Options for Storing Hierarchical Data in a Relational Database
- TanStack Query — Optimistic Updates
- Zustand Documentation — Updating State
- MDN — Using Server-Sent Events
- WHATWG HTML Living Standard — Server-Sent Events (§9.2)