TanStack Query 集成
Server Component 解决了首屏数据获取,但客户端交互场景(搜索、筛选、分页、实时轮询、无限滚动)仍然需要强大的客户端数据管理。TanStack Query(React Query v5)是 React 生态最成熟的服务端状态管理库,本章讲清它在 Next.js 16 App Router 中的集成方式。
1. 为什么需要 TanStack Query
1.1 Server Component 的局限
Server Component 擅长:
├── 首屏数据获取(零延迟直连数据库)
├── SEO(HTML 直出)
└── 减少 JS Bundle
Server Component 不擅长:
├── 用户交互后的数据更新(搜索、筛选)
├── 实时数据(轮询、WebSocket)
├── 无限滚动 / 分页
├── 乐观更新(客户端即时反馈)
└── 跨组件状态共享(缓存同步)
1.2 TanStack Query 核心能力
TanStack Query 提供:
├── 自动缓存 + 后台重新获取
├── 窗口聚焦时自动刷新
├── 请求去重(多组件用同一数据不重复请求)
├── 无限滚动(useInfiniteQuery)
├── 乐观更新
├── 轮询(refetchInterval)
├── 离线支持
└── SSR Hydration(服务端预取 → 客户端无缝接管)
2. 基础配置
2.1 安装
pnpm add @tanstack/react-query @tanstack/react-query-devtools2.2 Provider 配置
// lib/query-provider.tsx
'use client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import { useState } from 'react'
export function QueryProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 数据 1 分钟内视为新鲜
gcTime: 5 * 60 * 1000, // 缓存保留 5 分钟
refetchOnWindowFocus: true, // 窗口聚焦时重新获取
retry: 1, // 失败重试 1 次
},
},
}),
)
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
)
}// app/layout.tsx
import { QueryProvider } from '@/lib/query-provider'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<QueryProvider>
{children}
</QueryProvider>
</body>
</html>
)
}2.3 API 客户端封装
// lib/api-client.ts
const API_BASE = '/api'
export async function apiClient<T>(
endpoint: string,
options?: RequestInit,
): Promise<T> {
const res = await fetch(`${API_BASE}${endpoint}`, {
headers: { 'Content-Type': 'application/json' },
...options,
})
if (!res.ok) {
const error = await res.json().catch(() => ({ message: 'Unknown error' }))
throw new Error(error.message || `API Error: ${res.status}`)
}
return res.json()
}3. 基础查询
3.1 useQuery
// hooks/use-chats.ts
import { useQuery } from '@tanstack/react-query'
import { apiClient } from '@/lib/api-client'
type Chat = {
id: string
title: string
model: string
updatedAt: string
}
export function useChats(model?: string) {
return useQuery({
queryKey: ['chats', { model }],
queryFn: () =>
apiClient<Chat[]>(`/chat${model ? `?model=${model}` : ''}`),
})
}// components/chat-list-client.tsx
'use client'
import { useChats } from '@/hooks/use-chats'
export function ChatListClient({ model }: { model?: string }) {
const { data: chats, isLoading, error } = useChats(model)
if (isLoading) return <ChatListSkeleton />
if (error) return <ErrorMessage error={error} />
return (
<ul>
{chats?.map((chat) => (
<li key={chat.id}>{chat.title}</li>
))}
</ul>
)
}3.2 queryKey 设计
// queryKey 是缓存的唯一标识,设计原则:层级递进
['chats'] // 所有对话
['chats', { model: 'gpt-4o' }] // 按模型筛选
['chats', chatId] // 单个对话
['chats', chatId, 'messages'] // 某个对话的消息
['user'] // 当前用户
['user', 'billing'] // 用户账单
// 批量失效:invalidateQueries({ queryKey: ['chats'] })
// 会失效所有以 ['chats'] 开头的查询4. SSR Hydration
4.1 服务端预取 + 客户端无缝接管
最佳实践:Server Component 预取数据 → TanStack Query 在客户端接管(无额外请求):
// app/(dashboard)/chat/page.tsx (Server Component)
import {
dehydrate,
HydrationBoundary,
QueryClient,
} from '@tanstack/react-query'
export default async function ChatPage() {
const queryClient = new QueryClient()
// 服务端预取
await queryClient.prefetchQuery({
queryKey: ['chats'],
queryFn: async () => {
const session = await auth()
return db.query.chats.findMany({
where: eq(chats.userId, session!.user.id),
})
},
})
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<ChatListClient />
</HydrationBoundary>
)
}// components/chat-list-client.tsx (Client Component)
'use client'
import { useChats } from '@/hooks/use-chats'
export function ChatListClient() {
const { data: chats, isLoading } = useChats()
// isLoading = false(因为服务端已经预取了数据)
// chats 立即可用,无需等待客户端请求
return (
<ul>
{chats?.map(chat => <li key={chat.id}>{chat.title}</li>)}
</ul>
)
}4.2 效果
传统 Client-Only:
T=0ms 空白页面
T=100ms JS 加载完成 → 显示 loading
T=400ms API 请求完成 → 显示数据
SSR Hydration:
T=0ms 服务端已渲染完整 HTML(带数据)
T=100ms JS 加载完成 → TanStack Query 接管
无额外请求,数据已在缓存中
5. 数据变更(Mutations)
5.1 useMutation
// hooks/use-create-chat.ts
import { useMutation, useQueryClient } from '@tanstack/react-query'
export function useCreateChat() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: { title: string; model: string }) =>
apiClient<Chat>('/chat', {
method: 'POST',
body: JSON.stringify(data),
}),
onSuccess: () => {
// 创建成功后刷新对话列表
queryClient.invalidateQueries({ queryKey: ['chats'] })
},
})
}// components/new-chat-button.tsx
'use client'
export function NewChatButton() {
const createChat = useCreateChat()
return (
<button
onClick={() => createChat.mutate({ title: '新对话', model: 'gpt-4o' })}
disabled={createChat.isPending}
>
{createChat.isPending ? '创建中...' : '新建对话'}
</button>
)
}5.2 乐观更新
export function useDeleteChat() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (chatId: string) =>
apiClient(`/chat/${chatId}`, { method: 'DELETE' }),
onMutate: async (chatId) => {
// 取消进行中的查询
await queryClient.cancelQueries({ queryKey: ['chats'] })
// 保存旧数据(用于回滚)
const previousChats = queryClient.getQueryData<Chat[]>(['chats'])
// 乐观更新
queryClient.setQueryData<Chat[]>(['chats'], (old) =>
old?.filter((c) => c.id !== chatId),
)
return { previousChats }
},
onError: (_err, _chatId, context) => {
// 失败时回滚
queryClient.setQueryData(['chats'], context?.previousChats)
},
onSettled: () => {
// 无论成功失败,都重新获取最新数据
queryClient.invalidateQueries({ queryKey: ['chats'] })
},
})
}6. 无限滚动
// hooks/use-chat-messages.ts
import { useInfiniteQuery } from '@tanstack/react-query'
export function useChatMessages(chatId: string) {
return useInfiniteQuery({
queryKey: ['chats', chatId, 'messages'],
queryFn: ({ pageParam }) =>
apiClient<{
messages: Message[]
nextCursor: string | null
}>(`/chat/${chatId}/messages?cursor=${pageParam ?? ''}&limit=20`),
initialPageParam: null as string | null,
getNextPageParam: (lastPage) => lastPage.nextCursor,
})
}// components/message-list.tsx
'use client'
export function MessageList({ chatId }: { chatId: string }) {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useChatMessages(chatId)
const messages = data?.pages.flatMap((page) => page.messages) ?? []
return (
<div>
{hasNextPage && (
<button
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage}
>
{isFetchingNextPage ? '加载中...' : '加载更多'}
</button>
)}
{messages.map((msg) => (
<MessageBubble key={msg.id} message={msg} />
))}
</div>
)
}7. 实时轮询
AI 对话场景——等待 AI 回复时定期拉取最新消息:
export function useChatPolling(chatId: string, isWaiting: boolean) {
return useQuery({
queryKey: ['chats', chatId, 'messages'],
queryFn: () => apiClient<Message[]>(`/chat/${chatId}/messages`),
refetchInterval: isWaiting ? 1000 : false, // 等待时每秒轮询,否则停止
})
}8. TanStack Query vs Server Component
| 维度 | Server Component | TanStack Query |
|---|---|---|
| 首屏数据 | ✅ 最优 | ⚠️ 需 Hydration |
| 交互后刷新 | router.refresh() | ✅ 自动/手动 |
| 无限滚动 | ❌ | ✅ useInfiniteQuery |
| 实时轮询 | ❌ | ✅ refetchInterval |
| 乐观更新 | useOptimistic(简单) | ✅ 完整方案 |
| 离线支持 | ❌ | ✅ |
| 跨组件缓存 | React cache() | ✅ queryKey 共享 |
组合使用:Server Component 获取首屏数据 → HydrationBoundary 传给 TanStack Query → 客户端接管后续交互。
本章小结
- TanStack Query 补充 Server Component 的客户端交互能力
- SSR Hydration:
prefetchQuery+HydrationBoundary实现零闪烁首屏 - queryKey 设计:层级递进,支持批量失效
- Mutations:
useMutation+invalidateQueries处理数据变更 - 乐观更新:
onMutate立即更新缓存,onError回滚,onSettled同步 - 无限滚动:
useInfiniteQuery+ cursor 分页 - 实时轮询:
refetchInterval条件轮询
下一章讲 tRPC 端到端类型安全。