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 key
role: 'user' | 'assistant' | 'system' | 'tool';
content: string; // markdown is fine — Response renders it
timestamp?: 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 list
const [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} />
<Conversation
key={active!.id} // key resets scroll state per thread
messages={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?