本页还没有译文
本页 49 段还没译成Português (BR),下面显示的是英文原文。Client Persistence
还没翻译译自英文(tanstack.com)本页 49 段都还没译
A ChatClient (and every framework useChat / createChat) keeps messages in
memory, so a reload or a crashed tab loses the whole conversation and any reply
that was still streaming. The persistence option fixes that from the browser
side: on reload it repaints the transcript, brings back a pending interrupt, and
rejoins a run that was mid-stream.
You need this whether or not you have a server:
- The browser owns the chat (SPA, offline-first, no server store). Pass a storage adapter and it holds the full transcript, restored on reload with no network.
- The server owns the chat (you use Chat persistence).
Set
persistence: trueand the client caches nothing: on reload it hydrates the thread from the server by itsthreadId, painting the conversation and rejoining any run still streaming. That is the server-authoritative mode below.
Turn it on
Pass a storage adapter as persistence and give the chat a stable threadId so
a reload finds the same record:
import {
fetchServerSentEvents,
localStoragePersistence,
useChat,
} from '@tanstack/ai-react'
function Chat() {
const { messages, sendMessage } = useChat({
threadId: 'support-chat',
connection: fetchServerSentEvents('/api/chat'),
persistence: localStoragePersistence(),
})
// ...render messages, call sendMessage(text)
}localStoragePersistence() needs no type argument and no codec: it defaults to
the chat record shape and a JSON codec. That is the whole opt-in.
The threadId is doing the real work here: it is the key the record is written
under and looked up by, so a fresh id per mount restores nothing. Derive it from
your own domain (a conversation id, a route param). Id map covers how
to pick one and how it differs from the runId the hook reports.
What a reload restores
The client stores one record per threadId, the transcript plus a small resume
pointer. On the next load useChat reads it and:
- Repaints the transcript from storage with no network. Sync adapters
(
localStorage/sessionStorage) hydrate during construction. IndexedDB hydrates asynchronously after the database opens. Until then,isHydratingistrue, so you can show a loading state instead of an empty chat. - Rehydrates a pending interrupt, so an approval prompt comes back exactly as it was.
- Rejoins an in-flight run, if a reply was still streaming when the page reloaded, so it finishes in place instead of freezing half-done. This one needs a durability-backed connection (a route that records the stream and exposes a replay handler); see Resumable streams.
Replay rebuilds reasoning, tool activity, and text from the durable log, including runs that emit reasoning before their first tool or text event. The completed activity remains in the transcript sent with the next message.
AG-UI activity rows
AG-UI activity events show as role: 'activity' rows in messages.
- A client storage adapter keeps each row with the same
idand content. persistence: truerestores the rows from the server only when the server has anactivitiesstore. See AG-UI activity rows on reload.
The next sendMessage does not send activity rows as model input.
Handle restored client tools
A live client tool runs automatically when its call arrives from the stream.
Hydration restores its pending execution but does not run the browser code
again, because that work may not be safe to repeat. The execution remains an
internal interrupt and does not appear in interrupts.
Use onInterruptStateChange to distinguish restored interrupt state from live
updates. Leaving a restored batch pending is the default. This example cancels
every restored batch instead:
import { useEffect, useState } from 'react'
import {
fetchServerSentEvents,
localStoragePersistence,
useChat,
} from '@tanstack/ai-react'
function Chat() {
const [restoredBatch, setRestoredBatch] = useState(false)
const { cancelInterrupts } = useChat({
threadId: 'support-chat',
connection: fetchServerSentEvents('/api/chat'),
persistence: localStoragePersistence(),
onInterruptStateChange(_state, { source }) {
setRestoredBatch(source === 'hydrate')
},
})
useEffect(() => {
if (restoredBatch) cancelInterrupts()
}, [cancelInterrupts, restoredBatch])
return null
}source is hydrate for state restored from an initial resume snapshot, a
client storage adapter, or server hydration. It is live for streamed and
client-initiated changes. cancelInterrupts() cancels the complete internal
batch, including visible approvals and hidden client-tool executions; it does
not selectively cancel one kind of interrupt.
Choose a mode
persistence takes a storage adapter or a boolean:
- an adapter (
persistence: localStoragePersistence()) is client-authoritative. trueis server-authoritative.false(or omitted) is off: messages live in memory only and a reload starts empty.
The generation hooks take the same option, boolean only. See Generation persistence.
An adapter: client-authoritative
Pass the adapter directly, persistence: localStoragePersistence(). The
transcript and the resume pointer both live in the browser. The client owns the
history; the server, if any, mirrors it. Best when the browser is the source of
truth: single-page apps, offline-first, one device, small to moderate
conversations.
true: server-authoritative
Pass persistence: true. The client stores nothing, no transcript and no resume
pointer. On mount useChat hydrates the thread from the server by its
threadId: it paints the stored transcript and, if a run is still generating,
tails it to completion. history: { pageSize } paints the newest window on
hydrate. Best when transcripts are large (localStorage is
synchronous and quota-bound), when the same conversation must open on another
device, or when you simply do not want message content in the browser.
You do not fetch or seed the transcript yourself. A reload and the same thread
opened on another device follow the identical path, because the thread id is the
stable key and the server resolves everything from it. No loader, no
initialMessages, no extra props. It needs a connection with a hydrate handler
(every built-in connection has one) and the server GET endpoint below.
Client: a connection, a stable threadId, and persistence: true:
import { fetchServerSentEvents, useChat } from '@tanstack/ai-react'
const connection = fetchServerSentEvents('/api/chat')
function Chat({ threadId }: { threadId: string }) {
const {
messages,
isHydrating,
hasOlderMessages,
loadOlderMessages,
sendMessage,
} = useChat({
threadId,
connection,
persistence: true,
history: { pageSize: 50 },
})
if (isHydrating) return <p>Loading conversation...</p>
return (
<div>
{hasOlderMessages ? (
<button
type="button"
onClick={() => {
void loadOlderMessages().catch(() => {
// Show a retry. Painted messages stay.
})
}}
>
Load older
</button>
) : null}
{messages.map((m) => (
<div key={m.id}>{m.role}</div>
))}
<button type="button" onClick={() => void sendMessage('hi')}>
Send
</button>
</div>
)
}Call loadOlderMessages() from your scroll handler. The library does not watch
the scrollbar.
isHydratingistruefrom the first render until the transcript is in place. If a run is still generating,isLoadingis alreadytruewhenisHydratingturnsfalse. If the load fails,isHydratingturnsfalseanderroris set.hasOlderMessagesfollowspage.truncatedon the last hydrate or older-page response.- Without
history, hydrate loads the full thread. historyis only valid withpersistence: true.- With
history.pageSize, send posts only the new turn. Reload posts the last user (the old assistant is already gone locally). Resume posts from that user through the painted assistant so the stored tool-call is not dropped. - If
loadOlderMessages()fails, the promise rejects and painted messages stay.hasOlderMessagesstays true. Catch the rejection in your UI.
Server: one GET endpoint next to your chat POST. Replay the durability
log when the request carries a resume cursor, otherwise return the stored
conversation with reconstructChat:
import { memoryStream, resumeServerSentEventsResponse } from '@tanstack/ai'
import { reconstructChat } from '@tanstack/ai-persistence'
import { persistence } from './persistence'
export function GET(request: Request): Response | Promise<Response> {
const durability = memoryStream(request)
// A reconnecting client carries a resume cursor (Last-Event-ID / ?offset and
// X-Run-Id / ?runId). Replay the log so the run finishes in place.
if (durability.resumeFrom() !== null) {
return resumeServerSentEventsResponse({ adapter: durability })
}
// Otherwise return the stored transcript plus a cursor to any in-flight run.
// Guard access in multi-user apps (see authorize in Chat persistence).
return reconstructChat(persistence, request)
}reconstructChat returns { messages, activeRun }: the transcript as UI
messages, and activeRun when a run is still generating for the thread. With a
valid limit query param, the body also has page: { truncated, cursor }.
reconstructChat reads limit and before from the query.
The client calls this endpoint on mount and, when activeRun is set, tails the
run through the replay branch above. You never handle a run id, and a second
device resumes the live run the same way the original tab does. See
Chat persistence.
Show how long each turn took
A live turn can time itself on the client. After a reload, that time is gone. Pass includeRuns: true to get it back from the server:
import { reconstructChat } from '@tanstack/ai-persistence'
import { persistence } from './persistence'
export function GET(request: Request): Promise<Response> {
return reconstructChat(persistence, request, { includeRuns: true })
}Each assistant message of a finished run then has the run's timings, in epoch ms, on metadata.tanstack.run:
import type { UIMessage } from '@tanstack/ai'
function TurnDuration({ message }: { message: UIMessage }) {
const run = message.metadata?.tanstack?.run
if (!run?.finishedAt) return null
const seconds = Math.round((run.finishedAt - run.startedAt) / 1000)
return <span>Worked for {seconds}s</span>
}The response also has a top-level runs list with runId, status, startedAt, and finishedAt for each finished run. The timings need a runs store that implements listByThread, and messages that were saved by withPersistence with this version or later.
| Mode | Caches on client | Authoritative history | Reach for it when |
|---|---|---|---|
persistence: store | transcript + resume pointer | client | SPA / offline, one device, small to moderate history |
persistence: true | nothing | server | large histories, multi-device, no transcripts in the browser |
Choose a storage backend
Three adapters ship from @tanstack/ai-client, re-exported from every framework
package. All share the same shape; they differ in lifetime and encoding.
| Adapter | Lifetime | Notes | Reach for it when |
|---|---|---|---|
localStoragePersistence | across reloads and browser restarts | synchronous, ~5MB quota, JSON codec | the default: persist a conversation for next time |
sessionStoragePersistence | one tab, cleared when it closes | same shape as localStorage | a conversation that should not outlive the tab |
indexedDBPersistence | across reloads and restarts | async, structured clone (a Date round-trips exactly), room for large data | big transcripts, or values a JSON codec would mangle |
import { indexedDBPersistence } from '@tanstack/ai-react'
const persistence = indexedDBPersistence()Each throws only lazily, per operation, when its backing store is missing (for example during server-side rendering), so constructing one on the server is safe.
Writing your own
Any object with getItem / setItem / removeItem works. The record is one
{ messages, resume? } blob per chat id (the transcript plus the pointer that
lets a reload rejoin an in-flight run), so setItem receives that whole record,
not a bare message array:
import type {
ChatClientPersistence,
ChatPersistedState,
} from '@tanstack/ai-client'
function isPersistedState(value: unknown): value is ChatPersistedState {
return (
typeof value === 'object' &&
value !== null &&
'messages' in value &&
Array.isArray(value.messages)
)
}
const persistence: ChatClientPersistence = {
getItem(id) {
const raw = localStorage.getItem(id)
if (raw === null) return null
const parsed: unknown = JSON.parse(raw)
// A bare array is the legacy messages-only format, still accepted.
if (Array.isArray(parsed)) return { messages: parsed }
return isPersistedState(parsed) ? parsed : null
},
setItem(id, state) {
localStorage.setItem(id, JSON.stringify(state))
},
removeItem(id) {
localStorage.removeItem(id)
},
}Reads are best-effort: a getItem that throws or returns null is treated as
"nothing stored", so an adapter that parses the wrong shape fails silently:
the conversation just does not come back. Round-trip your adapter once against a
real reload before shipping it.
Client and server are independent
Client persistence restores what one browser rendered. Server persistence (Chat persistence) keeps the authoritative copy for every user and survives a server restart. They compose: for the combination we recommend for most apps, and why, see the Persistence overview.