Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/chat-client-persistence.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@tanstack/ai-client': minor
'@tanstack/ai-react': minor
'@tanstack/ai-preact': minor
'@tanstack/ai-solid': minor
'@tanstack/ai-svelte': minor
'@tanstack/ai-vue': minor
---

Add persistence support for chat messages.
142 changes: 142 additions & 0 deletions docs/chat/persistence.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
---
title: Persistence
id: chat-persistence
order: 5
description: "Persist chat conversations on the client with TanStack AI — hydrate on load, save on change, and clear on reset using a simple getItem/setItem/removeItem adapter."
keywords:
- tanstack ai
- persistence
- chat history
- localStorage
- indexeddb
- offline
- hydration
---

By default a `ChatClient` (and every framework `useChat`/`createChat` wrapper) keeps messages in memory only — reload the page or navigate away and the conversation is gone. The optional **persistence adapter** wires the client to a storage backend so conversations survive reloads, with no manual `initialMessages` + `onFinish` boilerplate.

This is especially useful for SPAs, Electron apps, and offline-first setups where the client is the source of truth and there's no server managing conversation state.

## The adapter interface

A persistence adapter is any object with three methods — the same `getItem`/`setItem`/`removeItem` shape used elsewhere in TanStack AI. Each method may be synchronous or return a `Promise`:

```typescript
import type { ChatClientPersistence } from "@tanstack/ai-client";

interface ChatClientPersistence {
getItem: (
id: string,
) =>
| Array<UIMessage>
| null
| undefined
| Promise<Array<UIMessage> | null | undefined>;
setItem: (id: string, messages: Array<UIMessage>) => void | Promise<void>;
removeItem: (id: string) => void | Promise<void>;
}
```

The `id` passed to each method is the client's `id` option. Provide a stable `id` per conversation so the right history is loaded back:

```typescript
const client = new ChatClient({
id: "conversation-123",
connection: adapter,
persistence: myPersistenceAdapter,
});
```

## What the client does for you

When a `persistence` adapter is provided, `ChatClient`:

- **Hydrates on construction** — calls `getItem(id)`. If it returns an array, those messages populate the client (overriding `initialMessages`). Async adapters hydrate as soon as the promise resolves, unless you've already started a new conversation in the meantime.
- **Saves on every change** — calls `setItem(id, messages)` whenever the message list changes (new user message, streamed assistant content, tool calls/results, approval responses). Writes are queued so they never overlap or land out of order.
- **Clears on `clear()`** — calls `removeItem(id)` and discards any in-flight stream so a cleared conversation doesn't get repopulated by late chunks.

When `persistence` is omitted, nothing changes — the client behaves exactly as before. The option is fully backwards compatible.

Persistence is **best-effort**: if an adapter method throws or rejects, the error is swallowed so storage problems never break the chat. Handle and surface errors inside your adapter if you need to react to them.

## Framework usage

Every framework wrapper accepts the same `persistence` option and forwards it to the underlying `ChatClient`:

```tsx
// React / Preact
const chat = useChat({
id: "conversation-123",
connection: fetchServerSentEvents("/api/chat"),
persistence: myPersistenceAdapter,
});
```

```ts
// Solid / Vue — same option
const chat = useChat({
id: "conversation-123",
connection: fetchServerSentEvents("/api/chat"),
persistence: myPersistenceAdapter,
});
```

```ts
// Svelte
const chat = createChat({
id: "conversation-123",
connection: fetchServerSentEvents("/api/chat"),
persistence: myPersistenceAdapter,
});
```

## Example: `localStorage`

A synchronous adapter backed by `localStorage`. Note that `UIMessage.createdAt` is a `Date`, which `JSON.stringify` turns into a string — revive it on read if you depend on it:

```typescript
import type { ChatClientPersistence, UIMessage } from "@tanstack/ai-client";

const localStoragePersistence: ChatClientPersistence = {
getItem: (id) => {
const raw = window.localStorage.getItem(id);
if (!raw) return null;
return (JSON.parse(raw) as Array<UIMessage>).map((message) => ({
...message,
createdAt:
typeof message.createdAt === "string"
? new Date(message.createdAt)
: message.createdAt,
}));
},
setItem: (id, messages) => {
window.localStorage.setItem(id, JSON.stringify(messages));
},
removeItem: (id) => {
window.localStorage.removeItem(id);
},
};
```

## Example: IndexedDB (async)

For larger histories or structured queries, back the adapter with an async store such as IndexedDB. The client awaits async methods automatically:

```typescript
import type { ChatClientPersistence } from "@tanstack/ai-client";

const indexedDbPersistence: ChatClientPersistence = {
getItem: async (id) => {
const record = await db.conversations.get(id);
return record?.messages;
},
setItem: async (id, messages) => {
await db.conversations.put({ id, messages, updatedAt: Date.now() });
},
removeItem: async (id) => {
await db.conversations.delete(id);
},
};
```

Any backend works — IndexedDB, SQLite (Electron/Tauri), a remote database, or an in-memory `Map` for tests — as long as it implements the three methods.
4 changes: 4 additions & 0 deletions docs/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,10 @@
{
"label": "Thinking & Reasoning",
"to": "chat/thinking-content"
},
{
"label": "Persistence",
"to": "chat/persistence"
}
]
},
Expand Down
14 changes: 14 additions & 0 deletions examples/ts-react-chat/src/components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
Image,
Menu,
Mic,
MessageSquare,
Music,
Server,
Video,
Expand Down Expand Up @@ -214,6 +215,19 @@ export default function Header() {
<span className="font-medium">Guitar Demo</span>
</Link>

<Link
to="/threads"
onClick={() => setIsOpen(false)}
className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-800 transition-colors mb-2"
activeProps={{
className:
'flex items-center gap-3 p-3 rounded-lg bg-cyan-600 hover:bg-cyan-700 transition-colors mb-2',
}}
>
<MessageSquare size={20} />
<span className="font-medium">Persistent Chats</span>
</Link>

<Link
to="/example/runtime-context"
onClick={() => setIsOpen(false)}
Expand Down
21 changes: 21 additions & 0 deletions examples/ts-react-chat/src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.

import { Route as rootRouteImport } from './routes/__root'
import { Route as ThreadsRouteImport } from './routes/threads'
import { Route as ServerFnChatRouteImport } from './routes/server-fn-chat'
import { Route as RealtimeRouteImport } from './routes/realtime'
import { Route as Issue176ToolResultRouteImport } from './routes/issue-176-tool-result'
Expand Down Expand Up @@ -39,6 +40,11 @@ import { Route as ApiGenerateSpeechRouteImport } from './routes/api.generate.spe
import { Route as ApiGenerateImageRouteImport } from './routes/api.generate.image'
import { Route as ApiGenerateAudioRouteImport } from './routes/api.generate.audio'

const ThreadsRoute = ThreadsRouteImport.update({
id: '/threads',
path: '/threads',
getParentRoute: () => rootRouteImport,
} as any)
const ServerFnChatRoute = ServerFnChatRouteImport.update({
id: '/server-fn-chat',
path: '/server-fn-chat',
Expand Down Expand Up @@ -196,6 +202,7 @@ export interface FileRoutesByFullPath {
'/issue-176-tool-result': typeof Issue176ToolResultRoute
'/realtime': typeof RealtimeRoute
'/server-fn-chat': typeof ServerFnChatRoute
'/threads': typeof ThreadsRoute
'/api/image-gen': typeof ApiImageGenRoute
'/api/image-tool-repro': typeof ApiImageToolReproRoute
'/api/structured-chat': typeof ApiStructuredChatRoute
Expand Down Expand Up @@ -227,6 +234,7 @@ export interface FileRoutesByTo {
'/issue-176-tool-result': typeof Issue176ToolResultRoute
'/realtime': typeof RealtimeRoute
'/server-fn-chat': typeof ServerFnChatRoute
'/threads': typeof ThreadsRoute
'/api/image-gen': typeof ApiImageGenRoute
'/api/image-tool-repro': typeof ApiImageToolReproRoute
'/api/structured-chat': typeof ApiStructuredChatRoute
Expand Down Expand Up @@ -259,6 +267,7 @@ export interface FileRoutesById {
'/issue-176-tool-result': typeof Issue176ToolResultRoute
'/realtime': typeof RealtimeRoute
'/server-fn-chat': typeof ServerFnChatRoute
'/threads': typeof ThreadsRoute
'/api/image-gen': typeof ApiImageGenRoute
'/api/image-tool-repro': typeof ApiImageToolReproRoute
'/api/structured-chat': typeof ApiStructuredChatRoute
Expand Down Expand Up @@ -292,6 +301,7 @@ export interface FileRouteTypes {
| '/issue-176-tool-result'
| '/realtime'
| '/server-fn-chat'
| '/threads'
| '/api/image-gen'
| '/api/image-tool-repro'
| '/api/structured-chat'
Expand Down Expand Up @@ -323,6 +333,7 @@ export interface FileRouteTypes {
| '/issue-176-tool-result'
| '/realtime'
| '/server-fn-chat'
| '/threads'
| '/api/image-gen'
| '/api/image-tool-repro'
| '/api/structured-chat'
Expand Down Expand Up @@ -354,6 +365,7 @@ export interface FileRouteTypes {
| '/issue-176-tool-result'
| '/realtime'
| '/server-fn-chat'
| '/threads'
| '/api/image-gen'
| '/api/image-tool-repro'
| '/api/structured-chat'
Expand Down Expand Up @@ -386,6 +398,7 @@ export interface RootRouteChildren {
Issue176ToolResultRoute: typeof Issue176ToolResultRoute
RealtimeRoute: typeof RealtimeRoute
ServerFnChatRoute: typeof ServerFnChatRoute
ThreadsRoute: typeof ThreadsRoute
ApiImageGenRoute: typeof ApiImageGenRoute
ApiImageToolReproRoute: typeof ApiImageToolReproRoute
ApiStructuredChatRoute: typeof ApiStructuredChatRoute
Expand All @@ -412,6 +425,13 @@ export interface RootRouteChildren {

declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/threads': {
id: '/threads'
path: '/threads'
fullPath: '/threads'
preLoaderRoute: typeof ThreadsRouteImport
parentRoute: typeof rootRouteImport
}
'/server-fn-chat': {
id: '/server-fn-chat'
path: '/server-fn-chat'
Expand Down Expand Up @@ -626,6 +646,7 @@ const rootRouteChildren: RootRouteChildren = {
Issue176ToolResultRoute: Issue176ToolResultRoute,
RealtimeRoute: RealtimeRoute,
ServerFnChatRoute: ServerFnChatRoute,
ThreadsRoute: ThreadsRoute,
ApiImageGenRoute: ApiImageGenRoute,
ApiImageToolReproRoute: ApiImageToolReproRoute,
ApiStructuredChatRoute: ApiStructuredChatRoute,
Expand Down
Loading
Loading