React Query 服务端数据脱水与客户端水合:dehydrate 与 HydrationBoundary 实战

主题: react-query-hydration-boundary-mismatch更新于: 2026/7/7作者:AgentFactory 技术团队

快速答案

  • 核心结论dehydrateHydrationBoundary 用于将服务端预取的数据(如 Next.js Server Components 中的数据)传递给客户端,避免客户端重复请求,保持状态一致。
  • 第一检查点:确保服务端数据是纯 JSON 可序列化对象(如 JSON.parse(JSON.stringify(data))),否则 hydrate 会失败。
  • 最小配置:在服务端组件中调用 dehydrate(queryClient) 获取状态,在客户端用 <HydrationBoundary state={dehydratedState}> 包裹组件树。
  • 适用边界:仅适用于 React 18+ 的 SSR/SSG 框架(Next.js App Router、Remix 等),不适用于纯客户端应用。

它解决什么问题

在 SSR/SSG 框架中,服务端预取的数据(如用户信息、文章列表)需要传递给客户端。如果客户端重新发起请求,会浪费带宽和时间。dehydrate 将服务端 QueryClient 的状态序列化为 JSON,HydrationBoundary 在客户端将这些状态注入到客户端的 QueryClient 中,实现数据无缝传递。

典型场景:

  • Next.js App Router 中 Server Components 预取数据后传递给 Client Components
  • Remix 的 loader 中预取数据后传递给页面组件
  • 静态生成(SSG)时预取数据,避免客户端首次加载时空白

核心 API 参数说明

dehydrate(queryClient, options?)

参数必填说明
client需要脱水的 QueryClient 实例
options.shouldDehydrateMutation自定义哪些 mutation 应被脱水(默认只脱水成功查询)
options.shouldDehydrateQuery自定义哪些 query 应被脱水(默认只脱水成功查询)
options.serializeData自定义数据序列化函数(如加密、脱敏)
options.shouldRedactErrors是否脱敏错误信息(默认 true,生产环境建议保持)

HydrationBoundary(props)

参数必填说明
statedehydrate 返回的状态对象
options.defaultOptions水合后的默认 query/mutation 选项
options.deserializeData自定义数据反序列化函数
options.queryClient使用自定义 QueryClient(默认从最近的 QueryClientProvider 获取)

hydrate(queryClient, dehydratedState, options?)

参数必填说明
queryClient要注入状态的 QueryClient 实例
dehydratedStatedehydrate 返回的状态对象
options.defaultOptions水合后的默认 query/mutation 选项
options.deserializeData自定义数据反序列化函数

实战配置:Next.js App Router 完整示例

服务端组件(Server Component)

TSX
// app/page.tsx
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query'
import { getPosts } from './api'
import PostsList from './PostsList'

export default async function PostsPage() {
  const queryClient = new QueryClient()

  // 服务端预取数据
  await queryClient.prefetchQuery({
    queryKey: ['posts'],
    queryFn: getPosts,
  })

  // 脱水:将 QueryClient 状态序列化为 JSON
  const dehydratedState = dehydrate(queryClient)

  return (
    <HydrationBoundary state={dehydratedState}>
      <PostsList />
    </HydrationBoundary>
  )
}

客户端组件(Client Component)

TSX
// app/PostsList.tsx
'use client'

import { useQuery } from '@tanstack/react-query'
import { getPosts } from './api'

export default function PostsList() {
  // 客户端不会重新请求,直接使用服务端预取的数据
  const { data } = useQuery({
    queryKey: ['posts'],
    queryFn: getPosts,
    staleTime: 5 * 60 * 1000, // 5分钟内不重新获取
  })

  return (
    <ul>
      {data?.map(post => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  )
}

关键配置说明

  1. staleTime 必须大于 0:默认 staleTime 为 0,客户端会立即重新请求。设置 staleTime: 5 * 60 * 1000 让客户端信任服务端数据。
  2. 查询键必须完全一致:服务端 prefetchQuery 和客户端 useQueryqueryKey 必须相同。
  3. 数据必须是 JSON 可序列化:服务端返回的 Date 对象、MapSet 等类型需要手动转换。

常见报错与排查

1. Text content did not match

报错信息Text content did not match. Server: "..." Client: "..."

根因:客户端和服务端渲染的 HTML 不一致,通常由动态数据(如 Date.now()Math.random())或浏览器特有 API 引起。

解决方案

TSX
// 错误示例:直接在组件中调用 Date.now()
function TimeDisplay() {
  return <div>{Date.now()}</div> // 服务端和客户端渲染结果不同
}

// 正确做法:使用 useEffect 同步
function TimeDisplay() {
  const [time, setTime] = useState(0)
  useEffect(() => {
    setTime(Date.now())
  }, [])
  return <div>{time}</div>
}

2. toLocaleDateString is not a function

报错信息TypeError: toLocaleDateString is not a function

根因:服务端传递的 Date 对象在序列化时被转换为 ISO 字符串,客户端无法调用 Date 方法。

解决方案

TSX
// 服务端:将 Date 转换为 ISO 字符串
const data = {
  user: {
    name: 'Alice',
    registeredAt: new Date().toISOString(), // 转换为字符串
  }
}

// 客户端:重新实例化为 Date
const { data } = useQuery({
  queryKey: ['user'],
  queryFn: fetchUser,
  select: (raw) => ({
    ...raw,
    user: {
      ...raw.user,
      registeredAt: new Date(raw.user.registeredAt), // 重新转为 Date
    }
  })
})

3. Hydration failed because the initial UI does not match

报错信息Hydration failed because the initial UI does not match what was rendered on the server

根因:数据在服务端和客户端不一致,或使用了客户端特有的 API。

解决方案

  • 检查是否在服务端组件中使用了 localStoragewindow 等浏览器 API
  • 使用 useSyncExternalStoreSuspense 边界包裹动态内容
  • 确保数据在两次渲染间不发生变化

4. Cannot read properties of undefined (reading 'map')

报错信息TypeError: Cannot read properties of undefined (reading 'map')

根因:服务端返回的数据结构在客户端解析失败,通常是因为返回了 ORM 模型或类实例。

解决方案

TSX
// 服务端:确保返回纯 JSON
const posts = await db.post.findMany()
const serializedPosts = JSON.parse(JSON.stringify(posts)) // 去除 ORM 模型属性

生产环境实践与注意事项

数据序列化限制

数据类型是否支持处理方式
普通对象/数组原生支持
Date手动转换为 ISO 字符串
Map/Set转换为数组或对象
类实例转换为普通对象
BigInt转换为字符串
undefined转换为 null 或忽略

安全性建议

  1. 避免脱水敏感数据:密码、令牌、密钥等不应出现在脱水状态中。
  2. 使用 serializeData 脱敏
TSX
const dehydratedState = dehydrate(queryClient, {
  serializeData: (data) => {
    // 脱敏敏感字段
    if (data?.user?.password) {
      return { ...data, user: { ...data.user, password: undefined } }
    }
    return data
  }
})
  1. 生产环境保持 shouldRedactErrors 为 true:避免泄露服务端错误详情。

缓存合并策略

HydrationBoundary 使用基于更新时间戳的智能合并策略:

  • 如果客户端已有相同查询且数据更新,不会覆盖(除非服务端数据更新)
  • 默认只脱水成功查询,减少不必要数据传输
  • 可以通过 shouldDehydrateQuery: () => true 强制脱水所有查询(包括失败查询)

常见问题 FAQ

Q: 如何确保服务端错误(如 500)也能被客户端捕获并显示?

A: 在 dehydrate 时设置 shouldDehydrateQuery: () => trueshouldRedactErrors: () => false,这样错误信息会包含在脱水状态中。但注意:生产环境建议脱敏错误详情,避免泄露敏感信息。

TSX
const dehydratedState = dehydrate(queryClient, {
  shouldDehydrateQuery: () => true,
  shouldRedactErrors: () => false, // 生产环境建议保持 true
})

Q: HydrationBoundary 和手动调用 hydrate 有什么区别?

A: HydrationBoundary 是 React 组件,自动从最近的 QueryClientProvider 获取 queryClient,适合在组件树中使用。手动 hydrate 需要显式传入 queryClient,适合在非 React 环境(如 localStorage 持久化)或需要精细控制时使用。

TSX
// 手动 hydrate 示例(适合非 React 环境)
import { hydrate } from '@tanstack/react-query'

const queryClient = new QueryClient()
hydrate(queryClient, dehydratedState, {
  defaultOptions: {
    queries: { staleTime: 5000 },
  },
})

Q: 为什么我的查询在客户端被重复请求,即使已经脱水?

A: 可能原因:

  1. 脱水时未包含该查询(默认只包含成功查询)
  2. 客户端查询的 staleTime 为 0,导致 React Query 认为数据过期并重新获取
  3. 查询键不一致(服务端和客户端使用不同的键)

解决方法:

TSX
// 确保查询键完全一致
const queryKey = ['posts', { page: 1 }]

// 服务端
await queryClient.prefetchQuery({ queryKey, queryFn: fetchPosts })

// 客户端
const { data } = useQuery({ queryKey, queryFn: fetchPosts, staleTime: 5 * 60 * 1000 })

Q: 如何与 React Server Components 结合使用?

A: 在 Server Components 中预取数据并脱水,在 Client Components 中使用 HydrationBoundary 包裹。注意:Server Components 中不能使用 useQuery,只能使用 prefetchQueryfetchQuery

TSX
// Server Component
export default async function Page() {
  const queryClient = new QueryClient()
  await queryClient.prefetchQuery({
    queryKey: ['data'],
    queryFn: () => fetch('https://api.example.com/data').then(r => r.json()),
  })
  const dehydratedState = dehydrate(queryClient)
  
  return (
    <HydrationBoundary state={dehydratedState}>
      <ClientComponent />
    </HydrationBoundary>
  )
}

相关深度解决方案

在配置当前服务时,如果您需要实现更复杂的架构或多源数据整合,建议配合参考我们整理的 Next.js App Router Metadata 不更新?排查与解决方案

在配置当前服务时,如果您需要实现更复杂的架构或多源数据整合,建议配合参考我们整理的 React useEffect 无限循环修复:5分钟诊断与解决方案