Skip to content
Dashboard

How to use TanStack Query for server state, SSR, and streaming

Copy link to headingServer state needs a contract

Copy link to headingWhat TanStack Query manages

Copy link to headingCore primitives to learn first

export const threadKeys = {
all: ['threads'] as const,
list: (workspaceId: string) =>
[...threadKeys.all, { workspaceId }] as const,
detail: (threadId: string) =>
[...threadKeys.all, 'detail', threadId] as const,
messages: (threadId: string) =>
[...threadKeys.detail(threadId), 'messages'] as const,
liveMessages: (threadId: string) =>
[...threadKeys.detail(threadId), 'live'] as const,
}

import { useQuery } from '@tanstack/react-query'
export function useThread(threadId: string) {
return useQuery({
queryKey: threadKeys.detail(threadId),
queryFn: () => fetch(`/api/threads/${threadId}`).then((res) => res.json()),
staleTime: 30_000,
})
}

import { useMutation, useQueryClient } from '@tanstack/react-query'
export function useRenameThread(threadId: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (title: string) => {
const res = await fetch(`/api/threads/${threadId}`, {
method: 'PATCH',
body: JSON.stringify({ title }),
})
if (!res.ok) throw new Error('Rename failed')
return res.json()
},
onMutate: async (title) => {
await queryClient.cancelQueries({
queryKey: threadKeys.detail(threadId),
})
const previous = queryClient.getQueryData(threadKeys.detail(threadId))
queryClient.setQueryData(threadKeys.detail(threadId), (thread: any) =>
thread ? { ...thread, title } : thread,
)
return { previous }
},
onError: (_error, _title, context) => {
queryClient.setQueryData(threadKeys.detail(threadId), context?.previous)
},
onSettled: () =>
queryClient.invalidateQueries({
queryKey: threadKeys.detail(threadId),
}),
})
}

Copy link to headingSSR begins on the server

import { QueryClient } from '@tanstack/react-query'
export function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
},
},
})
}

Copy link to headingNext.js App Router pattern

app/threads/[threadId]/page.tsx
import {
dehydrate,
HydrationBoundary,
QueryClient,
} from '@tanstack/react-query'
import { ThreadView } from './thread-view'
export default async function Page({
params,
}: {
params: Promise<{ threadId: string }>
}) {
const { threadId } = await params
const queryClient = new QueryClient()
await queryClient.prefetchQuery({
queryKey: threadKeys.detail(threadId),
queryFn: () => getThread(threadId),
})
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<ThreadView threadId={threadId} />
</HydrationBoundary>
)
}

app/threads/[threadId]/thread-view.tsx
'use client'
import { useQuery } from '@tanstack/react-query'
export function ThreadView({ threadId }: { threadId: string }) {
const { data: thread, isPending } = useQuery({
queryKey: threadKeys.detail(threadId),
queryFn: () => fetch(`/api/threads/${threadId}`).then((res) => res.json()),
})
if (isPending) return <p>Loading...</p>
if (!thread) return null
return <h1>{thread.title}</h1>
}

Copy link to headingNuxt, SvelteKit, Astro, and Remix

Copy link to headingNuxt uses a plugin boundary

plugins/vue-query.ts
import {
VueQueryPlugin,
QueryClient,
dehydrate,
hydrate,
type DehydratedState,
} from '@tanstack/vue-query'
export default defineNuxtPlugin((nuxt) => {
const queryClient = new QueryClient()
const state = useState<DehydratedState | null>('vue-query', () => null)
nuxt.vueApp.use(VueQueryPlugin, { queryClient })
if (import.meta.server) {
nuxt.hooks.hook('app:rendered', () => {
state.value = dehydrate(queryClient)
})
}
if (import.meta.client) {
hydrate(queryClient, state.value)
}
})

Copy link to headingSvelteKit prefetches in load

src/routes/+layout.ts
import { browser } from '$app/environment'
import { QueryClient } from '@tanstack/svelte-query'
export async function load() {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
enabled: browser,
},
},
})
return { queryClient }
}

src/routes/+layout.svelte
<script lang="ts">
import { QueryClientProvider } from '@tanstack/svelte-query'
import type { LayoutData } from './$types'
export let data: LayoutData
</script>
<QueryClientProvider client={data.queryClient}>
<slot />
</QueryClientProvider>

src/routes/+page.ts
export async function load({ parent, fetch }) {
const { queryClient } = await parent()
await queryClient.prefetchQuery({
queryKey: ['posts'],
queryFn: async () => (await fetch('/api/posts')).json(),
})
}

src/routes/+page.svelte
<script lang="ts">
import { createQuery } from '@tanstack/svelte-query'
const posts = createQuery(() => ({
queryKey: ['posts'],
queryFn: async () => (await fetch('/api/posts')).json(),
}))
</script>
{#if posts.data}
{#each posts.data as post}
<article>{post.title}</article>
{/each}
{/if}

Copy link to headingAstro passes initial data to islands

---
import ThreadIsland from '../components/thread-island.tsx'
const thread = await fetch(`${Astro.url.origin}/api/thread`).then((res) =>
res.json(),
)
---
<ThreadIsland client:load initialThread={thread} />

import { useQuery } from '@tanstack/react-query'
export default function ThreadIsland({ initialThread }: any) {
const { data } = useQuery({
queryKey: threadKeys.detail(initialThread.id),
queryFn: () =>
fetch(`/api/threads/${initialThread.id}`).then((res) => res.json()),
initialData: initialThread,
})
return <h2>{data.title}</h2>
}

Copy link to headingRemix puts hydration in loaders

app/routes/threads.$threadId.tsx
import { json } from '@remix-run/node'
import { useLoaderData } from '@remix-run/react'
import {
dehydrate,
HydrationBoundary,
QueryClient,
useQuery,
} from '@tanstack/react-query'
export async function loader({ params }: any) {
const queryClient = new QueryClient()
await queryClient.prefetchQuery({
queryKey: threadKeys.detail(params.threadId),
queryFn: () => getThread(params.threadId),
})
return json({ dehydratedState: dehydrate(queryClient) })
}
export default function Route() {
const { dehydratedState } = useLoaderData<typeof loader>()
return (
<HydrationBoundary state={dehydratedState}>
<Thread />
</HydrationBoundary>
)
}

Copy link to headingOptimistic updates need rollback paths

Copy link to headingInfinite lists need stable cursors

import { useInfiniteQuery } from '@tanstack/react-query'
export function useThreadMessages(threadId: string) {
return useInfiniteQuery({
queryKey: threadKeys.messages(threadId),
queryFn: ({ pageParam }) =>
fetch(`/api/threads/${threadId}/messages?cursor=${pageParam}`).then(
(res) => res.json(),
),
initialPageParam: 'latest',
getNextPageParam: (lastPage) => lastPage.nextCursor,
})
}

Copy link to headingAgent threads are server state

'use client'
import { useChat } from '@ai-sdk/react'
import { DefaultChatTransport } from 'ai'
import { useMutation, useQueryClient } from '@tanstack/react-query'
export function AgentThread({ threadId }: { threadId: string }) {
const queryClient = useQueryClient()
const chat = useChat({
transport: new DefaultChatTransport({
api: `/api/threads/${threadId}/chat`,
}),
onFinish: () => {
queryClient.invalidateQueries({
queryKey: threadKeys.detail(threadId),
})
},
onError: () => {
queryClient.invalidateQueries({
queryKey: threadKeys.messages(threadId),
})
},
})
const sendMessage = useMutation({
mutationFn: ({ id, text }: { id: string; text: string }) =>
chat.sendMessage({ text, messageId: id }),
onMutate: async ({ id, text }) => {
await queryClient.cancelQueries({
queryKey: threadKeys.liveMessages(threadId),
})
const previous = queryClient.getQueryData(
threadKeys.liveMessages(threadId),
)
queryClient.setQueryData(
threadKeys.liveMessages(threadId),
(
messages: Array<{
id: string
role: 'user'
content: string
status: 'pending'
}> = [],
) => [
...messages,
{ id, role: 'user', content: text, status: 'pending' },
],
)
return { previous }
},
onError: (_error, _draft, context) => {
queryClient.setQueryData(
threadKeys.liveMessages(threadId),
context?.previous,
)
},
onSettled: () => {
queryClient.invalidateQueries({
queryKey: threadKeys.messages(threadId),
})
},
})
return (
<form
onSubmit={(event) => {
event.preventDefault()
const form = event.currentTarget
const text = new FormData(form).get('text') as string
sendMessage.mutate({
id: crypto.randomUUID(),
text,
})
form.reset()
}}
>
{chat.messages.map((message) => (
<Message key={message.id} message={message} status={chat.status} />
))}
<input name="text" />
</form>
)
}

Copy link to headingStreaming tokens belong in cache

type ToolStatus = 'pending' | 'running' | 'completed' | 'errored'
type ThreadMessage = {
id: string
role: 'user' | 'assistant'
content: string
toolCalls?: Record<string, { name: string; status: ToolStatus }>
}
export function appendToken(
queryClient: QueryClient,
threadId: string,
messageId: string,
delta: string,
) {
queryClient.setQueryData(
threadKeys.liveMessages(threadId),
(messages: ThreadMessage[] = []) =>
messages.map((message) =>
message.id === messageId
? { ...message, content: message.content + delta }
: message,
),
)
}

export function setToolStatus(
queryClient: QueryClient,
threadId: string,
messageId: string,
toolCallId: string,
status: ToolStatus,
) {
queryClient.setQueryData(
threadKeys.liveMessages(threadId),
(messages: ThreadMessage[] = []) =>
messages.map((message) =>
message.id === messageId
? {
...message,
toolCalls: {
...message.toolCalls,
[toolCallId]: {
name: message.toolCalls?.[toolCallId]?.name ?? 'tool',
status,
},
},
}
: message,
),
)
}

lib/chat.ts
// lib/chat.ts
import { Chat } from 'chat'
import { createSlackAdapter } from '@chat-adapter/slack'
import { createRedisState } from '@chat-adapter/state-redis'
export const workspaceChat = new Chat({
userName: 'agent',
adapters: {
slack: createSlackAdapter(),
},
state: createRedisState(),
})

app/api/threads/[threadId]/chat/route.ts
// app/api/threads/[threadId]/chat/route.ts
import { convertToModelMessages, streamText } from 'ai'
import { createChatTools } from 'chat/ai'
import { workspaceChat } from '@/lib/chat'
export const maxDuration = 800
export async function POST(req: Request) {
const { messages } = await req.json()
const result = streamText({
model: process.env.AI_MODEL!,
messages: await convertToModelMessages(messages),
tools: createChatTools({
chat: workspaceChat,
preset: ['reader', 'messenger'],
}),
})
return result.toUIMessageStreamResponse({
originalMessages: messages,
})
}

Copy link to headingFluid compute for long-lived, I/O-heavy AI requests

Copy link to headingTanStack Query FAQ

Copy link to headingDoes TanStack Query replace Redux or Zustand?

Copy link to headingShould every framework use the same query keys?

Copy link to headingShould server-rendered pages always hydrate TanStack Query?

Copy link to headingShould chat tokens live in TanStack Query?

Ready to deploy?