State & threads
Slipstream components are stateless: they render the messages you pass and emit actions through callbacks. Everything else — threads, persistence, optimistic updates — is your store. This guide is the recommended shape for that store.
The message shape
typescript
interface Message {id: string; // stable — used as React key, streaming target, error map keyrole: 'user' | 'assistant' | 'system' | 'tool';content: string; // markdown is fine — Response renders ittimestamp?: Date;metadata?: Record<string, unknown>; // toolCalls, sources, branch info…}
- Never reuse ids — streaming, retry cards, and error maps all key off them.
- Put auxiliary data (tool calls, citations) in
metadata, not in content markup. - Append optimistically for the user turn; replace-or-fill the assistant turn while streaming.
Minimal store
tsx
type Thread = {id: string;title: string;updatedAt: number;messages: Message[];streamingId?: string;errors?: Record<string, string>; // messageId -> retryable failure};// One hook, plain state — swap for Zustand/Jotai/Redux/SWR freely.function useThread(initial: Thread) {const [thread, setThread] = useState(initial);const patch = (id: string, content: string) =>setThread((t) => ({ ...t, messages: t.messages.map((m) => (m.id === id ? { ...m, content } : m)) }));const fail = (id: string, message: string) =>setThread((t) => ({ ...t, streamingId: undefined, errors: { ...t.errors, [id]: message } }));return { thread, setThread, patch, fail };}
Persistence
tsx
// Save on idle, not per token: debounce the whole transcript.useEffect(() => {const t = setTimeout(() => saveThread(thread.id, stripEphemeral(thread)), 800);return () => clearTimeout(t);}, [thread]);// Reload older pages WITHOUT shifting the reader:function prependHistory(thread: Thread, older: Message[]): Thread {return { ...thread, messages: [...older, ...thread.messages] };}// Conversation keeps scroll anchoring stable when the top grows.
Multi-thread structure
Slipstream has no Thread component baked in — a thread list is just data next to the active thread. The pattern used by the templates:
tsx
const [threads, setThreads] = useState<Thread[]>([]); // sidebar listconst [activeId, setActiveId] = useState<string>();const active = threads.find((t) => t.id === activeId);// Only the ACTIVE thread streams; background threads stay frozen snapshots.<ThreadList threads={threads} activeId={activeId} onSelect={setActiveId} /><Conversationkey={active!.id} // key resets scroll state per threadmessages={active!.messages}streamingMessageId={active!.streamingId}/>
Branching
Regeneration should add a variant, not overwrite. Store variants per message id and hand the active variant to the list — Branch renders the N/M navigator. Full recipe: multi-branch conversations.
Rules of thumb
- Components never fetch — your store owns transport and persistence.
- Serialize your own state; nothing Slipstream renders is required to round-trip.
- Stream into one id, fail into an error map keyed by the same id, retry by re-sending that id.
Was this page helpful?