Fetching server data in a React app usually starts with a `useState` for the result, another for loading, a third for errors, and a `useEffect` to wire them together. That holds up until a second component needs the same record, or the data changes after it loads. At that point, you are hand-rolling a cache: deduping requests, tracking staleness, retrying failures, and refetching after writes.

[TanStack Query](https://tanstack.com/query/latest/docs/framework/react/overview) standardizes that work by giving your app a consistent way to manage server state. It is an async state library built for data that lives on a server and keeps changing after the first fetch. You write the async function that fetches or updates data (the promise-returning function), and TanStack Query handles everything around it: it gives that data a stable cache identity, decides when it’s fresh or stale, retries failures, refetches in the background, supports optimistic updates, and then reconciles the cache after mutations so the UI stays in sync.

Once a product surface spans multiple frameworks, you need a shared way to manage server state. TanStack Query supports React, Preact, Vue, Solid, Svelte, Lit and Angular through dedicated [framework adapters](https://tanstack.com/query/v5/docs/framework), so the mental model can stay stable while the component syntax changes.

As applications evolve from simple CRUD to streaming AI agents, async state management gets harder, which is the problem TanStack Query is built to solve.

## [Copy link to heading](#server-state-needs-a-contract)Server state needs a contract

Every query is identified by a `queryKey`, and that key is what makes invalidation predictable across views. The query declares which resource the UI wants, which variables identify it, how fresh the cached answer should be, and what should happen when a write makes that answer suspect.

Think of each query as a contract for a piece of server state, not a single request. A request ends when `fetch` resolves, but server state keeps changing after the response arrives. Other users edit records, background jobs finish, agents append messages, and webhooks update status. TanStack Query lets the client retain a useful answer while still treating freshness as a managed property.

TanStack Query manages that contract with a _stale-while-revalidate_ approach, where it shows the last cached value right away, treats it as potentially out of date, and refetches in the background when something meaningful changes, like the user navigating, the UI regaining focus, or a mutation updating the underlying data.

## [Copy link to heading](#what-tanstack-query-manages)What TanStack Query manages

When you adopt TanStack Query, start by drawing a clear line between server state and local UI state. TanStack Query is for remote, asynchronous data: data fetched from an API, database, or backend service that needs caching, refetching, synchronization, loading states, and error handling.

Local UI state should usually stay in React state or a client-state store. That includes text input, modal visibility, selected tabs, hover state, temporary form drafts, and other state that exists only within the current user session.

Use this split between server state and local UI state:

- **Queries** read async data and cache the result under a `queryKey`.

- **Mutations** write data and give you success, pending, error, retry, and rollback hooks.

- **Query keys** identify resources with serializable arrays, such as `['thread', threadId]` or `['projects', { cursor }]`.

- **Invalidation** marks cached data stale after a write, then refetches active queries in the background.

- **Freshness settings** like `staleTime` prevent a hydrated page from refetching immediately when the server already rendered useful data.

| Stack | Adapter | Common primitive |
| --- | --- | --- |
| React and Next.js | `@tanstack/react-query` | `useQuery` |
| Preact | `@tanstack/preact-query` | `useQuery` |
| Vue and Nuxt | `@tanstack/vue-query` | `useQuery` |
| Svelte and SvelteKit | `@tanstack/svelte-query` | `createQuery` |
| Solid | `@tanstack/solid-query` | `useQuery` |
| Lit (experimental) | `@tanstack/lit-query` | `createQueryController` |
| Angular | `@tanstack/angular-query-experimental` | `injectQuery` |

The fetcher can call REST, GraphQL, tRPC, a server action endpoint, or any promise-returning function.

## [Copy link to heading](#core-primitives-to-learn-first)Core primitives to learn first

A reusable query setup starts with typed key factories. They keep invalidation precise because every list, detail view, and child resource shares a predictable namespace.

```
1export const threadKeys = {
2  all: ['threads'] as const,
3  list: (workspaceId: string) =>
4    [...threadKeys.all, { workspaceId }] as const,
5  detail: (threadId: string) =>
6    [...threadKeys.all, 'detail', threadId] as const,
7  messages: (threadId: string) =>
8    [...threadKeys.detail(threadId), 'messages'] as const,
9  liveMessages: (threadId: string) =>
10    [...threadKeys.detail(threadId), 'live'] as const,
11}
```

The basic read path then becomes small enough to repeat across adapters.

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

The write path decides how much confidence the UI should show before the server confirms the result. A low-risk rename can update the cache optimistically and roll back on error. A payment action should wait for the server.

```
1import { useMutation, useQueryClient } from '@tanstack/react-query'
2export function useRenameThread(threadId: string) {
3  const queryClient = useQueryClient()
4  return useMutation({
5    mutationFn: async (title: string) => {
6      const res = await fetch(`/api/threads/${threadId}`, {
7        method: 'PATCH',
8        body: JSON.stringify({ title }),
9      })
10      if (!res.ok) throw new Error('Rename failed')
11      return res.json()
12    },
13    onMutate: async (title) => {
14      await queryClient.cancelQueries({
15        queryKey: threadKeys.detail(threadId),
16      })
17      const previous = queryClient.getQueryData(threadKeys.detail(threadId))
18      queryClient.setQueryData(threadKeys.detail(threadId), (thread: any) =>
19        thread ? { ...thread, title } : thread,
20      )
21      return { previous }
22    },
23    onError: (_error, _title, context) => {
24      queryClient.setQueryData(threadKeys.detail(threadId), context?.previous)
25    },
26    onSettled: () =>
27      queryClient.invalidateQueries({
28        queryKey: threadKeys.detail(threadId),
29      }),
30  })
31}
```

## [Copy link to heading](#ssr-begins-on-the-server)SSR begins on the server

Server-Side Rendering (SSR) is the process of fetching data and generating fully populated HTML on the server, rather than sending a blank shell and forcing the user's browser to build the UI from scratch. Four steps move the cache across the network boundary: create the query client for the request, prefetch the data needed for the first paint, dehydrate the cache, and hydrate it on the client. TanStack's [server rendering and hydration](https://tanstack.com/query/v5/docs/framework/react/guides/ssr) docs also call out the serialization boundary for custom SSR setups.

The reason to do this is practical. The server renders useful HTML, the browser receives the same query data, and the client avoids an immediate duplicate fetch. A nonzero `staleTime` usually belongs in SSR setups so the hydrated data remains fresh long enough for the page to become interactive.

```
1import { QueryClient } from '@tanstack/react-query'
2export function makeQueryClient() {
3  return new QueryClient({
4    defaultOptions: {
5      queries: {
6        staleTime: 60 * 1000,
7      },
8    },
9  })
10}
```

The data source does not need to know which framework rendered the page. The adapter decides how the prefetched cache crosses the server-client boundary.

## [Copy link to heading](#next.js-app-router-pattern)Next.js App Router pattern

Server Components own the initial data fetch, and Client Components own interactivity. Prefetch in the Server Component and wrap the Client Component with `HydrationBoundary` to pass the cache between them; background refetching and user-triggered mutations then run on the client.

app/threads/\[threadId\]/page.tsx

```
1import {
2  dehydrate,
3  HydrationBoundary,
4  QueryClient,
5} from '@tanstack/react-query'
6import { ThreadView } from './thread-view'
7export default async function Page({
8  params,
9}: {
10  params: Promise<{ threadId: string }>
11}) {
12  const { threadId } = await params
13  const queryClient = new QueryClient()
14  await queryClient.prefetchQuery({
15    queryKey: threadKeys.detail(threadId),
16    queryFn: () => getThread(threadId),
17  })
18  return (
19    <HydrationBoundary state={dehydrate(queryClient)}>
20      <ThreadView threadId={threadId} />
21    </HydrationBoundary>
22  )
23}
```

The Client Component then reads the same query from the hydrated cache, so its first render uses the prefetched data instead of starting a new fetch.

app/threads/\[threadId\]/thread-view.tsx

```
1'use client'
2import { useQuery } from '@tanstack/react-query'
3export function ThreadView({ threadId }: { threadId: string }) {
4  const { data: thread, isPending } = useQuery({
5    queryKey: threadKeys.detail(threadId),
6    queryFn: () => fetch(`/api/threads/${threadId}`).then((res) => res.json()),
7  })
8  if (isPending) return <p>Loading...</p>
9  if (!thread) return null
10  return <h1>{thread.title}</h1>
11}
```

This leaves React Server Components and TanStack Query with different jobs: RSC streams HTML, while TanStack Query keeps the hydrated client coherent after interaction starts.

## [Copy link to heading](#nuxt,-sveltekit,-astro,-and-remix)Nuxt, SvelteKit, Astro, and Remix

The same model works across the frameworks teams already ship on Vercel, but each adapter crosses the SSR boundary in its own idiom.

### [Copy link to heading](#nuxt-uses-a-plugin-boundary)Nuxt uses a plugin boundary

Nuxt 3 apps can create a Vue Query client in a plugin, dehydrate it on the server, and hydrate it from Nuxt state in the browser.

plugins/vue-query.ts

```
1import {
2  VueQueryPlugin,
3  QueryClient,
4  dehydrate,
5  hydrate,
6  type DehydratedState,
7} from '@tanstack/vue-query'
8export default defineNuxtPlugin((nuxt) => {
9  const queryClient = new QueryClient()
10  const state = useState<DehydratedState | null>('vue-query', () => null)
11  nuxt.vueApp.use(VueQueryPlugin, { queryClient })
12  if (import.meta.server) {
13    nuxt.hooks.hook('app:rendered', () => {
14      state.value = dehydrate(queryClient)
15    })
16  }
17  if (import.meta.client) {
18    hydrate(queryClient, state.value)
19  }
20})
```

### [Copy link to heading](#sveltekit-prefetches-in-load)SvelteKit prefetches in load

SvelteKit's pattern starts in a layout that creates the query client. Use the SvelteKit `browser` module to prevent normal queries from running during SSR, while still allowing explicit prefetching.

src/routes/+layout.ts

```
1import { browser } from '$app/environment'
2import { QueryClient } from '@tanstack/svelte-query'
3export async function load() {
4  const queryClient = new QueryClient({
5    defaultOptions: {
6      queries: {
7        enabled: browser,
8      },
9    },
10  })
11  return { queryClient }
12}
```

The layout component then receives that client from `load` and wraps the route tree in `QueryClientProvider` so every child can use it.

src/routes/+layout.svelte

```
1<script lang="ts">
2  import { QueryClientProvider } from '@tanstack/svelte-query'
3  import type { LayoutData } from './$types'
4  export let data: LayoutData
5</script>
6<QueryClientProvider client={data.queryClient}>
7  <slot />
8</QueryClientProvider>
```

Then a page `load` can prefetch with the framework-provided `fetch`, and `createQuery` can read from the populated cache.

src/routes/+page.ts

```
1export async function load({ parent, fetch }) {
2  const { queryClient } = await parent()
3  await queryClient.prefetchQuery({
4    queryKey: ['posts'],
5    queryFn: async () => (await fetch('/api/posts')).json(),
6  })
7}
```

The page component then reads that same query with `createQuery`, pulling straight from the cache the load function already filled.

src/routes/+page.svelte

```
1<script lang="ts">
2  import { createQuery } from '@tanstack/svelte-query'
3  const posts = createQuery(() => ({
4    queryKey: ['posts'],
5    queryFn: async () => (await fetch('/api/posts')).json(),
6  }))
7</script>
8{#if posts.data}
9  {#each posts.data as post}
10    <article>{post.title}</article>
11  {/each}
12{/if}
```

The result is a page that ships with server-prefetched data on first paint, then hands reactivity to `createQuery` once the user starts interacting.

### [Copy link to heading](#astro-passes-initial-data-to-islands)Astro passes initial data to islands

Astro pages often render static or server-loaded HTML, then hydrate interactive islands. For a TanStack Query island, pass server-loaded data as `initialData` so the first client render starts warm.

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

The island component then takes that prop as initialData, so its first client render starts with server-loaded data rather than an empty cache.

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

The result is a mostly static Astro page with one warm, interactive island, hydrated with data the server has already fetched.

### [Copy link to heading](#remix-puts-hydration-in-loaders)Remix puts hydration in loaders

Remix loaders map cleanly to TanStack Query prefetching. The loader prefetches and returns the dehydrated cache; the route renders a `HydrationBoundary`. This pattern applies to Remix v2. In React Router v7, the Remix successor, loaders return plain objects, and imports come from `react-router`.

app/routes/threads.$threadId.tsx

```
1import { json } from '@remix-run/node'
2import { useLoaderData } from '@remix-run/react'
3import {
4  dehydrate,
5  HydrationBoundary,
6  QueryClient,
7  useQuery,
8} from '@tanstack/react-query'
9export async function loader({ params }: any) {
10  const queryClient = new QueryClient()
11  await queryClient.prefetchQuery({
12    queryKey: threadKeys.detail(params.threadId),
13    queryFn: () => getThread(params.threadId),
14  })
15  return json({ dehydratedState: dehydrate(queryClient) })
16}
17export default function Route() {
18  const { dehydratedState } = useLoaderData<typeof loader>()
19  return (
20    <HydrationBoundary state={dehydratedState}>
21      <Thread />
22    </HydrationBoundary>
23  )
24}
```

Solid and Angular follow the same division with different primitives. Solid Query uses Solid's reactive model, while Angular Query exposes `injectQuery`, so the cache contract stays recognizable even when the component syntax changes.

## [Copy link to heading](#optimistic-updates-need-rollback-paths)Optimistic updates need rollback paths

A chat message, a checkbox flip, a reorder, or a title edit can update the cache before the server confirms. A destructive admin action usually must wait for the server. The dividing line is the action's success rate and how much the user notices the wait.

The safest optimistic mutation does four things:

- Cancels in-flight reads for the resource being changed.

- Saves the previous cached value.

- Writes the optimistic value with a temporary ID or pending status.

- Rolls back on error and invalidates on settle.

That final invalidation is necessary because optimistic data is a guess. The server may add fields, normalize content, reject a tool call, or reorder a list after persistence, so the cache needs to reconcile against whatever the server actually returned.

## [Copy link to heading](#infinite-lists-need-stable-cursors)Infinite lists need stable cursors

Infinite queries are normal queries with a page shape. TanStack Query stores `pages` and `pageParams`, then gives the UI `fetchNextPage`, `hasNextPage`, and separate pending state for loading another page.

```
1import { useInfiniteQuery } from '@tanstack/react-query'
2export function useThreadMessages(threadId: string) {
3  return useInfiniteQuery({
4    queryKey: threadKeys.messages(threadId),
5    queryFn: ({ pageParam }) =>
6      fetch(`/api/threads/${threadId}/messages?cursor=${pageParam}`).then(
7        (res) => res.json(),
8      ),
9    initialPageParam: 'latest',
10    getNextPageParam: (lastPage) => lastPage.nextCursor,
11  })
12}
```

When you paginate a long list, the backend often uses a _cursor_ (a pointer like “start after message 123”) to fetch the next page. If new items are inserted while someone is paging, offsets can shift, causing the same row to appear twice or a row to be skipped. Stable cursors prevent duplicates and skips when new items arrive while someone is paging. Chat threads are a useful example: new messages keep arriving while a user is paging back through older messages. Keep the live, newly arriving messages under a separate key, such as `threadKeys.liveMessages(threadId)`, and invalidate the paginated query when persistence changes the underlying order.

## [Copy link to heading](#agent-threads-are-server-state)Agent threads are server state

AI chat makes TanStack Query's server-state model more obvious. A thread possesses every hallmark of complex server state: it is hosted remotely, shared across multiple actors, inherently asynchronous, and highly mutable. The user can submit a message, the assistant can stream a response, tools can run, and another client can load the same thread history.

A thread really has two halves. One is the live interaction as tokens stream in, the other is the durable record that other clients load later. Treat the thread as two kinds of state, and use different tooling for each.

For the live streaming experience, use the [AI SDK](https://ai-sdk.dev/docs/introduction). Its [useChat hook](https://ai-sdk.dev/docs/reference/ai-sdk-ui/use-chat) manages the in-flight interaction, including streaming tokens and input handling.

For the durable record of the thread, use TanStack Query. It owns the cached thread history that other views and clients load later.

When you need provider choice and fallbacks behind a single production endpoint, route model calls through the [AI Gateway](https://vercel.com/ai-gateway).

When the agent needs to operate inside Slack, Teams, Discord, Google Chat, Linear, or other work tools, [Chat SDK](https://chat-sdk.dev/docs) can expose those operations as AI SDK tools.

Here is how those two tools come together within a single client component. `useChat` drives the live stream, while a TanStack Query mutation writes the user's message into the live-message cache optimistically and invalidates the durable thread queries once the response settles.

```
1'use client'
2import { useChat } from '@ai-sdk/react'
3import { DefaultChatTransport } from 'ai'
4import { useMutation, useQueryClient } from '@tanstack/react-query'
5export function AgentThread({ threadId }: { threadId: string }) {
6  const queryClient = useQueryClient()
7  const chat = useChat({
8    transport: new DefaultChatTransport({
9      api: `/api/threads/${threadId}/chat`,
10    }),
11    onFinish: () => {
12      queryClient.invalidateQueries({
13        queryKey: threadKeys.detail(threadId),
14      })
15    },
16    onError: () => {
17      queryClient.invalidateQueries({
18        queryKey: threadKeys.messages(threadId),
19      })
20    },
21  })
22  const sendMessage = useMutation({
23    mutationFn: ({ id, text }: { id: string; text: string }) =>
24      chat.sendMessage({ text, messageId: id }),
25    onMutate: async ({ id, text }) => {
26      await queryClient.cancelQueries({
27        queryKey: threadKeys.liveMessages(threadId),
28      })
29      const previous = queryClient.getQueryData(
30        threadKeys.liveMessages(threadId),
31      )
32      queryClient.setQueryData(
33        threadKeys.liveMessages(threadId),
34        (
35          messages: Array<{
36            id: string
37            role: 'user'
38            content: string
39            status: 'pending'
40          }> = [],
41        ) => [
42          ...messages,
43          { id, role: 'user', content: text, status: 'pending' },
44        ],
45      )
46      return { previous }
47    },
48    onError: (_error, _draft, context) => {
49      queryClient.setQueryData(
50        threadKeys.liveMessages(threadId),
51        context?.previous,
52      )
53    },
54    onSettled: () => {
55      queryClient.invalidateQueries({
56        queryKey: threadKeys.messages(threadId),
57      })
58    },
59  })
60  return (
61    <form
62      onSubmit={(event) => {
63        event.preventDefault()
64        const form = event.currentTarget
65        const text = new FormData(form).get('text') as string
66        sendMessage.mutate({
67          id: crypto.randomUUID(),
68          text,
69        })
70        form.reset()
71      }}
72    >
73      {chat.messages.map((message) => (
74        <Message key={message.id} message={message} status={chat.status} />
75      ))}
76      <input name="text" />
77    </form>
78  )
79}
```

The client cache only needs to know which thread contract changed, regardless of which provider produced the response.

## [Copy link to heading](#streaming-tokens-belong-in-cache)Streaming tokens belong in cache

If your chat UI has ever shown a duplicated message, a spinner attached to the wrong reply, or streaming text that disappears on refresh, you’re seeing the same underlying problem. Your thread state is split across multiple stores. Keep three pieces of state together: persisted messages, the currently streaming assistant message, and tool-call status attached to the message that triggered it. The AI SDK message `parts` array gives the UI text parts and typed tool parts, while metadata can carry timestamps, model IDs, and token usage.

When streaming chunks arrive outside `useChat`, merge them into a live-message query key instead of creating a parallel store. Keep that key separate from an infinite-query key because infinite queries store `{ pages, pageParams }`, not a flat message array.

Here is that merge as a reusable helper. `appendToken` finds the streaming message by `id` and appends each new token delta to its cached `content`.

```
1type ToolStatus = 'pending' | 'running' | 'completed' | 'errored'
2type ThreadMessage = {
3  id: string
4  role: 'user' | 'assistant'
5  content: string
6  toolCalls?: Record<string, { name: string; status: ToolStatus }>
7}
8export function appendToken(
9  queryClient: QueryClient,
10  threadId: string,
11  messageId: string,
12  delta: string,
13) {
14  queryClient.setQueryData(
15    threadKeys.liveMessages(threadId),
16    (messages: ThreadMessage[] = []) =>
17      messages.map((message) =>
18        message.id === messageId
19          ? { ...message, content: message.content + delta }
20          : message,
21      ),
22  )
23}
```

Tool status uses the same approach. Store the tool call beside the assistant message, and update it from `pending` to `running` when execution starts, then to `completed` or `errored` when the result arrives.

```
1export function setToolStatus(
2  queryClient: QueryClient,
3  threadId: string,
4  messageId: string,
5  toolCallId: string,
6  status: ToolStatus,
7) {
8  queryClient.setQueryData(
9    threadKeys.liveMessages(threadId),
10    (messages: ThreadMessage[] = []) =>
11      messages.map((message) =>
12        message.id === messageId
13          ? {
14              ...message,
15              toolCalls: {
16                ...message.toolCalls,
17                [toolCallId]: {
18                  name: message.toolCalls?.[toolCallId]?.name ?? 'tool',
19                  status,
20                },
21              },
22            }
23          : message,
24      ),
25  )
26}
```

On the server, [Chat SDK tools](https://chat-sdk.dev/docs/ai/ai-sdk-tools) can be passed directly into AI SDK generation. Chat SDK requires a platform adapter and a state adapter. Use Redis, Postgres, or another production state adapter so subscriptions and locks survive across function instances. Use [Vercel Workflows](https://vercel.com/docs/workflows) when a tool needs durable execution or should continue outside the chat response lifecycle.

Start with the shared `Chat` instance on the server. It registers a platform adapter (Slack) and a Redis state adapter, so locks and subscriptions survive across function instances.

lib/chat.ts

```
1// lib/chat.ts
2import { Chat } from 'chat'
3import { createSlackAdapter } from '@chat-adapter/slack'
4import { createRedisState } from '@chat-adapter/state-redis'
5export const workspaceChat = new Chat({
6  userName: 'agent',
7  adapters: {
8    slack: createSlackAdapter(),
9  },
10  state: createRedisState(),
11})
```

Then hand that instance to the route that streams the response. `createChatTools` exposes the configured adapters as AI SDK tools, and `maxDuration = 800` gives the model time to stream tokens and wait on tool calls.

app/api/threads/\[threadId\]/chat/route.ts

```
1// app/api/threads/[threadId]/chat/route.ts
2import { convertToModelMessages, streamText } from 'ai'
3import { createChatTools } from 'chat/ai'
4import { workspaceChat } from '@/lib/chat'
5export const maxDuration = 800
6export async function POST(req: Request) {
7  const { messages } = await req.json()
8  const result = streamText({
9    model: process.env.AI_MODEL!,
10    messages: await convertToModelMessages(messages),
11    tools: createChatTools({
12      chat: workspaceChat,
13      preset: ['reader', 'messenger'],
14    }),
15  })
16  return result.toUIMessageStreamResponse({
17    originalMessages: messages,
18  })
19}
```

Optimistic user messages appear immediately; streaming assistant tokens update the assistant message in place; tool parts update their status in place; and the final persisted response invalidates the thread.

## [Copy link to heading](#fluid-compute-for-long-lived,-i/o-heavy-ai-requests)**Fluid compute for long-lived, I/O-heavy AI requests**

The backend half of this pattern is a long-lived function that spends much of its time waiting while the model streams tokens, tools call external APIs, and the client keeps the connection open. That is the workload [Fluid compute](https://vercel.com/docs/fluid-compute) is built for.

Fluid compute can run functions up to 800 seconds on Pro and Enterprise plans, and its pricing model separates active CPU from I/O wait time. Active CPU billing applies while code is executing and pauses when the function waits on external services, including model calls, with memory remaining provisioned for in-flight work.

On the client, the UI has to stay coherent while the thread moves from pending state to streamed output to final persisted data. TanStack Query owns that contract. On the server, Fluid Compute is a good fit for the long-lived requests that power agent experiences, such as token streaming, tool calls, and external I/O.

If you are building this pattern on Vercel, start by pairing TanStack Query for durable thread state with Fluid Compute for the chat route that needs to stay open.

## [Copy link to heading](#tanstack-query-faq)TanStack Query FAQ

### [Copy link to heading](#does-tanstack-query-replace-redux-or-zustand)Does TanStack Query replace Redux or Zustand?

TanStack Query replaces the server-state part of many Redux or Zustand setups. Client-only state, such as drafts, selected UI controls, canvas state, and modal visibility, still belongs in local state or a client-state store.

### [Copy link to heading](#should-every-framework-use-the-same-query-keys)Should every framework use the same query keys?

Shared query keys are useful when multiple frontends talk to the same API. A React app, a SvelteKit app, and a Nuxt app can all use the same resource naming convention, even though each adapter exposes different hooks or reactive primitives.

### [Copy link to heading](#should-server-rendered-pages-always-hydrate-tanstack-query)Should server-rendered pages always hydrate TanStack Query?

Hydration is worth it when the client will keep interacting with the same data after the first paint. A static documentation page can use server rendering alone. A dashboard, feed, editor, or agent thread usually benefits from cache hydration.

### [Copy link to heading](#should-chat-tokens-live-in-tanstack-query)Should chat tokens live in TanStack Query?

If they represent a durable state, yes. Streaming tokens should update the cached thread because the user will continue to see and interact with that content after the stream finishes. Ephemeral input state can stay inside the chat component, but the thread query should own persisted messages, pending assistant responses, and tool-call status.