Skip to content
Persistence (Advanced)

Id Map (Advanced)

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.

IdNamesLifetimeWho sets it
threadIdThe thing that runs belong to: a conversation, or a generation slotAs long as your app uses the same stringYou, from your own data
runIdOne execution: one streamed answer, one generation jobStarts with the execution, ends with itThe library

Set the ids

  1. Make a threadId from your own data: a conversation id, a product id, a route param.
  2. Pass that threadId to every hook that has persistence on.
  3. Read runId from the hook only when you must tell your server about the work that runs now.
tsx
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.

How it works

  • Persistence writes one record per threadId. A reload reads the record by threadId.
  • On useChat, the thread is the conversation. All runs add messages to one transcript.
  • On a generation hook, the thread is a slot. Each job gets its own record, and a reload restores the newest job in the slot.
  • A runId is made fresh for each execution. You never pick it.

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.

When restore does nothing

Look for one of these causes:

  • The thread id changed. A random value in the id makes a new key on each mount. Log the id on the client and on the server, and compare the two strings.
  • The client and the server use different ids. The hook files under one id and the middleware under another. They must match exactly.
  • You keyed on a run id. A fresh page load cannot find anything by runId alone. Restore starts from the thread.
  • Byte storage is off. A generation record never holds media bytes. status and error come back, but result stays null. Add byte storage to get the media back too.

Advanced

On generation, the thread is a slot

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 screenA good threadId
The hero image for a productproduct-${productId}-hero
The start frame of a videovideo-${videoId}-start-frame
The voice-over of a chapterchapter-${chapterId}-narration
A transcription of an uploadupload-${uploadId}-transcript
The support conversationchat-${conversationId}

Two rules follow from "one slot, latest job wins":

  • Two different things need two different ids. If a hero-image hook and a thumbnail hook share a thread, each one restores whatever ran last, in the UI of the other.
  • The same thing keeps its id forever. A regenerate is a new job in the same slot, not a new slot. That is why a reload lands on the newest attempt.

On chat, a run is one turn

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:

  • A whole tool loop is one run. The model calls a tool, gets the result, calls another tool, and writes the answer. However many loops the agentic cycle takes, that is one runId.
  • One user message can make several runs. An interrupt ends the run. The resume continues the same turn under a new runId. Approve two tools one after the other, and one message spans three runs.
  • A paused run is not in flight. While a run waits for an approval, runId is null.

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.

mermaid
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"]

On generation, a run is the job

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:

tsx
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.

The server uses the same thread id

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:

ts
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 without persistence

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.