Persistence matches rows by the id you choose and keep stable. A new id on each load returns an empty chat or the wrong image.
| Id | Names | Lifetime | Who sets it |
|---|---|---|---|
| threadId | The thing that runs belong to: a conversation, or a generation slot | As long as your app uses the same string | You, from your own data |
| runId | One execution: one streamed answer, one generation job | Starts with the execution, ends with it | The library |
import {
fetchServerSentEvents,
useChat,
useGenerateImage,
} from '@tanstack/ai-react'
export function ProductPage({ productId }: { productId: string }) {
// Chat: the thread id names a conversation.
const support = useChat({
threadId: `support-${productId}`,
connection: fetchServerSentEvents('/api/chat'),
persistence: true,
})
// Generation: the thread id names a slot that jobs fill.
const hero = useGenerateImage({
threadId: `product-${productId}-hero`,
connection: fetchServerSentEvents('/api/generate/image'),
persistence: true,
})
// Each hook also reports the id of whatever runs right now.
return (
<p>
chat run {support.runId ?? 'none'}, image job {hero.runId ?? 'none'}
</p>
)
}Reload the page. Each hook finds its record again, because the threadId strings are the same.
Warning
Do not put crypto.randomUUID() or useId() in a threadId. Each mount then gets a new key, and nothing restores.
threadId is the only identity on chat and generation clients. There is no separate id option.
Restore starts from the thread because a page that reloaded does not know the last runId. It does know its threadId, because your app made it from a product id or a route param. So the client sends the thread. The store answers with what happened in it, plus the run that still goes on, if there is one. Only then does the client read that run's delivery log.
Look for one of these causes:
A generation job does not add to anything. It makes one result. So each job gets its own record, linked to the thread. Restore gives back the most recent job for that thread: its status, its error, and its result metadata.
The first attempt, the retry, and the regenerate after a prompt change all land in one slot. The latest one is what the user sees. That is why a generation thread id reads like a place in your app:
| The thing on screen | A good threadId |
|---|---|
| The hero image for a product | product-${productId}-hero |
| The start frame of a video | video-${videoId}-start-frame |
| The voice-over of a chapter | chapter-${chapterId}-narration |
| A transcription of an upload | upload-${uploadId}-transcript |
| The support conversation | chat-${conversationId} |
Two rules follow from "one slot, latest job wins":
A run is everything between one RUN_STARTED and its RUN_FINISHED. Each hook reports the run that this client has in flight, or null when there is none.
On chat, the runs do not map one-to-one to what the user did:
So useChat().runId tells you what this client runs now. It does not tell you which message is which. In a live subscription, a run that another client started is not yours to cancel, so the hook does not report it.
flowchart TB
subgraph chat ["useChat, threadId: support-42"]
direction LR
c1["run r1
tool loop, one turn"] --> c2["run r2
interrupted"] --> c3["run r3
the resume of that same turn"]
end
subgraph gen ["useGenerateImage, threadId: product-7-hero"]
direction LR
g1["job g1
first attempt"] --> g2["job g2
retry"] --> g3["job g3
running"]
end
chat -. "one transcript, keyed by threadId" .-> cstore["messages store"]
gen -. "one record per job, newest restores" .-> gstore["generationRuns store"]One call to generate(...) is one job with one runId. There is no tool loop and no interrupt, so the mapping is one-to-one. The runId is the handle on the provider work in progress.
Send that id to your own server when you cancel. stop() only stops the local stream. It does not stop a video render that still costs credits at the provider:
import { fetchServerSentEvents, useGenerateVideo } from '@tanstack/ai-react'
export function VideoPanel({ videoId }: { videoId: string }) {
const video = useGenerateVideo({
threadId: `video-${videoId}-clip`,
connection: fetchServerSentEvents('/api/generate/video'),
persistence: true,
})
async function cancel() {
// Stop the provider job on the server, then drop the local stream.
if (video.runId) {
await fetch(`/api/generate/video/cancel?runId=${video.runId}`, {
method: 'POST',
})
}
video.stop()
}
return (
<button type="button" onClick={() => void cancel()} disabled={!video.runId}>
Cancel
</button>
)
}A durability log is keyed by the same id. So runId is also the right value for a log line when you follow one execution across your server. See Threads and runs for the protocol and Resumable streams for the log.
Persistence works only when the client and the server file under the same string. On the client, that is the hook's threadId. On the server, it is the activity's threadId. For generation, the middleware reads it from the activity, so you do not repeat it on withGenerationPersistence:
import {
generateImage,
generationParamsFromRequest,
toServerSentEventsResponse,
} from '@tanstack/ai'
import { openaiImage } from '@tanstack/ai-openai'
import {
memoryPersistence,
withGenerationPersistence,
} from '@tanstack/ai-persistence'
const persistence = memoryPersistence()
export async function POST(request: Request) {
const { input, threadId } = await generationParamsFromRequest('image', request)
if (typeof input.prompt !== 'string') {
throw new Error('This endpoint accepts text image prompts only.')
}
const stream = generateImage({
adapter: openaiImage('gpt-image-2.5-flare'),
prompt: input.prompt,
threadId,
stream: true,
middleware: [withGenerationPersistence(persistence)],
})
return toServerSentEventsResponse(stream)
}The client sends its threadId on the wire for you, so the hook only needs the option. Chat works the same way: the threadId that you pass to useChat is the one that chatParamsFromRequest gives your route, and withPersistence stores under it. See Chat persistence and Generation persistence for the full setup.
threadId is required on every hook, also without persistence. The id is also the DevTools row for the session. Without persistence, the run works, but nobody can load it again after a reload. That is fine for a one-shot image that you show and forget.
With persistence on, the threadId is also the key that the run is stored under. Pass the same id to the hook and to the activity that the middleware wraps.