본문으로 건너뛰기

클라이언트 영속성

ChatClient(및 모든 프레임워크의 useChat / createChat)는 메시지를 메모리에 보관하므로 새로 고침하거나 탭이 중단되면 전체 대화와 스트리밍 중이던 답변이 모두 사라집니다. persistence 옵션은 브라우저 측에서 이 문제를 해결합니다. 새로 고침하면 대화 기록을 다시 표시하고, 보류 중인 인터럽트를 복원하며, 스트리밍 중이던 실행에 다시 연결합니다.

서버가 있든 없든 이 기능이 필요합니다.

  • 브라우저가 채팅을 소유하는 경우(SPA, 오프라인 우선, 서버 저장소 없음): 스토리지 어댑터를 전달하면 전체 대화 기록을 보관하고, 네트워크 없이 새로 고침 시 복원합니다.
  • 서버가 채팅을 소유하는 경우(채팅 영속성을 사용하는 경우): persistence: true를 설정하면 클라이언트는 아무것도 캐시하지 않습니다. 새로 고침 시 threadId로 서버에서 스레드를 하이드레이션하여 대화를 표시하고, 아직 스트리밍 중인 실행에 다시 연결합니다. 아래에서 이 서버 권한 모드를 설명합니다.

활성화하기

스토리지 어댑터를 persistence로 전달하고 채팅에 안정적인 threadId를 지정하면 새로 고침 시 같은 레코드를 찾을 수 있습니다:

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()에는 타입 인수나 코덱이 필요하지 않습니다. 채팅 레코드 형식과 JSON 코덱을 기본값으로 사용합니다. 이것만으로 기능을 활성화할 수 있습니다.

여기서 실제로 중요한 역할을 하는 것은 threadId입니다. 레코드를 기록하고 조회할 때 사용하는 키이므로 마운트마다 새 ID를 사용하면 아무것도 복원되지 않습니다. 대화 ID나 라우트 매개변수처럼 자체 도메인에서 ID를 도출합니다. ID 맵에서 ID를 선택하는 방법과 훅이 보고하는 runId와의 차이를 설명합니다.

새로 고침 시 복원되는 항목

클라이언트는 threadId마다 대화 기록과 작은 재개 포인터가 포함된 레코드 하나를 저장합니다. 다음에 로드할 때 useChat은 이를 읽고 다음을 수행합니다.

  • 대화 기록을 다시 표시합니다. 네트워크 없이 스토리지에서 복원합니다. 동기 어댑터(localStorage / sessionStorage)는 생성 중 하이드레이션되고, IndexedDB는 데이터베이스가 열린 후 비동기로 하이드레이션됩니다(따라서 첫 렌더링은 잠시 비어 있을 수 있습니다).
  • 보류 중인 인터럽트를 다시 하이드레이션합니다. 승인 프롬프트가 이전과 정확히 같은 상태로 돌아옵니다.
  • 진행 중인 실행에 다시 연결합니다. 페이지를 새로 고칠 때 답변이 아직 스트리밍 중이었다면 중간에 멈추지 않고 해당 위치에서 완료됩니다. 이 기능에는 내구성이 지원되는 연결(스트림을 기록하고 재생 핸들러를 노출하는 라우트)이 필요합니다. 재개 가능한 스트림을 참조하세요.

복원된 클라이언트 도구 처리

라이브 클라이언트 도구는 호출이 스트림에서 도착하면 자동으로 실행됩니다. 하이드레이션은 보류 중인 실행을 복원하지만 해당 작업을 반복해도 안전하지 않을 수 있으므로 브라우저 코드를 다시 실행하지 않습니다. 실행은 내부 인터럽트로 남으며 interrupts에는 표시되지 않습니다.

onInterruptStateChange를 사용하면 복원된 인터럽트 상태와 라이브 업데이트를 구분할 수 있습니다. 복원된 배치를 보류 상태로 두는 것이 기본 동작입니다. 다음 예제는 대신 복원된 모든 배치를 취소합니다.

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
}

초기 재개 스냅샷, 클라이언트 스토리지 어댑터 또는 서버 하이드레이션에서 복원된 상태의 sourcehydrate입니다. 스트리밍으로 발생한 변경과 클라이언트가 시작한 변경에서는 live입니다. cancelInterrupts()는 표시되는 승인과 숨겨진 클라이언트 도구 실행을 포함해 전체 내부 배치를 취소하며, 특정 종류의 인터럽트만 선택적으로 취소하지는 않습니다.

모드 선택

persistence에는 스토리지 어댑터 또는 불리언을 지정합니다.

  • 어댑터(persistence: localStoragePersistence())는 클라이언트 권한 모드입니다.
  • **true**는 서버 권한 모드입니다.
  • false(또는 생략)는 꺼진 상태입니다. 메시지는 메모리에만 존재하며 새로 고침하면 빈 상태로 시작합니다.

생성 훅에는 같은 옵션을 지정하지만 불리언만 사용할 수 있습니다. 생성 영속성을 참조하세요.

어댑터: 클라이언트 권한 모드

어댑터를 persistence: localStoragePersistence()로 직접 전달합니다. 대화 기록과 재개 포인터는 모두 브라우저에 저장됩니다. 클라이언트가 기록을 소유하고 서버가 있다면 서버는 이를 미러링합니다. 브라우저가 진실의 원천인 경우, 즉 단일 페이지 앱, 오프라인 우선 환경, 단일 기기, 작거나 중간 규모의 대화에 적합합니다.

true: 서버 권한 모드

persistence: true를 전달합니다. 클라이언트에는 대화 기록이나 재개 포인터를 포함해 아무것도 저장되지 않습니다. 마운트 시 useChatthreadId로 서버에서 스레드를 하이드레이션하여 저장된 대화 기록을 표시하고, 실행이 아직 생성 중이면 완료될 때까지 이를 따라갑니다. 대화 기록이 크거나(localStorage는 동기식이며 할당량 제한이 있음), 같은 대화를 다른 기기에서 열어야 하거나, 단순히 브라우저에 메시지 내용을 저장하고 싶지 않을 때 적합합니다.

대화 기록을 직접 가져오거나 초기화할 필요가 없습니다. 스레드 ID가 안정적인 키이고 서버가 이를 바탕으로 모든 항목을 확인하므로, 새로 고침과 다른 기기에서 같은 스레드를 여는 작업은 동일한 경로를 따릅니다. 로더도, initialMessages도, 추가 속성도 필요하지 않습니다. hydrate 핸들러가 있는 연결(모든 기본 제공 연결에 포함됨)과 아래의 서버 GET 엔드포인트가 필요합니다.

클라이언트: 연결, 안정적인 threadId, persistence: true:

import { fetchServerSentEvents, useChat } from '@tanstack/ai-react'

const connection = fetchServerSentEvents('/api/chat')

function Chat({ threadId }: { threadId: string }) {
const { messages, sendMessage } = useChat({
threadId,
connection,
persistence: true,
})
return (
<div>
{messages.map((m) => (
<div key={m.id}>{m.role}</div>
))}
<button type="button" onClick={() => void sendMessage('hi')}>
Send
</button>
</div>
)
}

서버: 채팅 POST 옆에 GET 엔드포인트 하나를 추가합니다. 요청에 재개 커서가 있으면 내구성 로그를 재생하고, 그렇지 않으면 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은 UI 메시지 형태의 대화 기록인 { messages, activeRun }을 반환하며, 스레드에서 실행이 아직 생성 중이면 activeRun도 반환합니다. 클라이언트는 마운트 시 이 엔드포인트를 호출하고 activeRun이 설정되면 위의 재생 분기를 통해 실행을 따라갑니다. 실행 ID를 직접 처리할 필요가 없으며, 두 번째 기기도 원래 탭과 같은 방식으로 라이브 실행을 재개합니다. 채팅 영속성을 참조하세요.

모드클라이언트 캐시권한 있는 기록사용 시점
persistence: store대화 기록 + 재개 포인터클라이언트SPA / 오프라인, 단일 기기, 작거나 중간 규모의 기록
persistence: true없음서버큰 기록, 여러 기기, 브라우저에 대화 기록을 저장하지 않는 경우

스토리지 백엔드 선택

@tanstack/ai-client에서 세 가지 어댑터를 제공하며, 모든 프레임워크 패키지에서 다시 내보냅니다. 모두 같은 형식을 사용하지만 수명과 인코딩이 다릅니다.

어댑터수명참고사용 시점
localStoragePersistence새로 고침 및 브라우저 재시작 후에도 유지동기식, 약 5MB 할당량, JSON 코덱기본값: 다음에 사용할 수 있도록 대화 유지
sessionStoragePersistence탭 하나, 탭을 닫으면 삭제localStorage와 같은 형식탭보다 오래 유지되지 않아야 하는 대화
indexedDBPersistence새로 고침 및 재시작 후에도 유지비동기식, 구조화된 복제(Date가 정확히 왕복됨), 대용량 데이터에 적합큰 대화 기록 또는 JSON 코덱이 손상시킬 수 있는 값
import { indexedDBPersistence } from '@tanstack/ai-react'

const persistence = indexedDBPersistence()

각 어댑터는 백업 스토리지가 없을 때(예: 서버 측 렌더링 중)에만 작업별로 지연 실패하므로 서버에서 생성해도 안전합니다.

직접 작성하기

getItem / setItem / removeItem이 있는 객체라면 무엇이든 사용할 수 있습니다. 레코드는 채팅 ID마다 하나의 { messages, resume? } blob(대화 기록과 새로 고침 시 진행 중인 실행에 다시 연결할 수 있게 하는 포인터)이므로 setItem은 단순한 메시지 배열이 아니라 전체 레코드를 받습니다:

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)
},
}

읽기는 최선의 방법으로 수행됩니다. 예외를 발생시키거나 null을 반환하는 getItem은 "저장된 항목 없음"으로 처리되므로 잘못된 형식을 파싱하는 어댑터는 조용히 실패합니다. 대화가 복원되지 않을 뿐입니다. 배포하기 전에 실제 새로 고침을 통해 어댑터의 저장과 복원을 한 번 확인하세요.

클라이언트와 서버는 독립적입니다

클라이언트 영속성은 한 브라우저에 렌더링된 내용을 복원합니다. 서버 영속성 (채팅 영속성)은 모든 사용자의 권한 있는 사본을 유지하며 서버가 재시작되어도 보존됩니다. 두 기능은 함께 사용할 수 있습니다. 대부분의 앱에 권장하는 조합과 그 이유는 영속성 개요를 참조하세요.