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-devtools

2.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 ComponentTanStack Query
首屏数据✅ 最优⚠️ 需 Hydration
交互后刷新router.refresh()✅ 自动/手动
无限滚动useInfiniteQuery
实时轮询refetchInterval
乐观更新useOptimistic(简单)✅ 完整方案
离线支持
跨组件缓存React cache()✅ queryKey 共享

组合使用:Server Component 获取首屏数据 → HydrationBoundary 传给 TanStack Query → 客户端接管后续交互。

本章小结

  • TanStack Query 补充 Server Component 的客户端交互能力
  • SSR HydrationprefetchQuery + HydrationBoundary 实现零闪烁首屏
  • queryKey 设计:层级递进,支持批量失效
  • MutationsuseMutation + invalidateQueries 处理数据变更
  • 乐观更新onMutate 立即更新缓存,onError 回滚,onSettled 同步
  • 无限滚动useInfiniteQuery + cursor 分页
  • 实时轮询refetchInterval 条件轮询

下一章讲 tRPC 端到端类型安全。