TanStack Query 与 Suspense 集成

解释 useSuspenseQuery 如何把 pending 和 error 交给 React Boundary,以及它与条件查询、预取、取消和流式状态的边界。

#type / howto #status / evergreen #tech / dev / frontend #resource / react

[!info] related notes

TanStack Query 与 Suspense 集成

[!abstract] 学习目标 能根据数据所有权选择 useQueryuseSuspenseQuery,放置合理的 Suspense / Error Boundary,并识别条件查询、请求瀑布和 SSE 流式状态不适合直接照搬 Suspense 示例的原因。

它改变的是渲染控制流

普通 useQuery 把 pending、error、data 都作为返回状态交给组件判断;useSuspenseQuery 在数据未就绪时暂停当前渲染,把 fallback 交给最近的 Suspense Boundary。

function UserPanel({ userId }: { userId: string }) {
  const { data } = useSuspenseQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
  });

  return <h2>{data.displayName}</h2>;
}

function Page({ userId }: { userId: string }) {
  return (
    <ErrorBoundary fallback={<UserError />}>
      <Suspense fallback={<UserSkeleton />}>
        <UserPanel userId={userId} />
      </Suspense>
    </ErrorBoundary>
  );
}

类型上的直接收益是 data 保证存在;代价是 pending 与部分 error 分支离开当前组件,进入 Boundary 结构。

与 useQuery 的关键差异

问题useQueryuseSuspenseQuery
data`Tundefined`
条件启用支持 enabled不提供 enabled
占位数据支持 placeholderData不提供
pending UI组件读取状态Suspense fallback
error UI组件或 Boundary通常使用 Error Boundary
取消可使用 query 取消机制官方文档注明不支持取消

需要条件查询时,先在父组件判断参数是否存在,再渲染调用 useSuspenseQuery 的子组件;不要把可能为空的 ID 用非空断言硬塞进去。

Error Boundary 的真实语义

useSuspenseQuery 不允许自定义 throwOnError。默认策略是:缓存里没有可显示数据时将错误抛给最近的 Error Boundary;如果已有旧数据,组件仍可渲染旧数据并获得 error / fetching 状态。

如果产品要求“任何错误都进入 Boundary”,可以在组件中显式判断并 throw error。重试时应配合 QueryErrorResetBoundaryuseQueryErrorResetBoundary 重置 Query 错误状态,而不是粗暴清空整个 QueryClient。

Boundary 放置决定加载体验

  • 放得过高:一个次要卡片会让整个页面退回大 fallback。
  • 放得过低:页面出现大量闪烁的小 skeleton,结构复杂。
  • 较稳定的做法:按用户可感知的内容块放置,并让 Error Boundary 与恢复按钮覆盖同一责任区域。

预取用于提前启动请求

Suspense 是“等待期间渲染什么”,不是“请求何时开始”。如果父组件先 suspend,子组件的请求可能还没有启动,形成瀑布。

可在路由 loader、导航事件或父层使用 ensureQueryData / prefetchQuery 提前启动请求。多个互不依赖的 Suspense Query 放在同一组件时可能串行,应考虑 useSuspenseQueries 或更早预取。

React use(query.promise) 的实验边界

TanStack Query 允许在开启 experimental_prefetchInRender 后,把 useQuery().promise 交给 React use()。这不是普通 useQuery 的默认行为,也不应因为课程展示就直接写入生产代码。

QueryClient 开启实验选项
  -> useQuery 返回稳定 promise
  -> React use(promise)
  -> Suspense Boundary 接管等待

优先使用稳定的 useSuspenseQuery;只有明确需要并接受实验 API 风险时再评估 use(query.promise)

BodySense 为什么目前继续使用 useQuery

useConsultationSessionQuery.ts 使用:

  • conversationId: string | null
  • enabled: !!conversationId
  • 自定义 staleTime
  • 关闭窗口聚焦、重连和挂载时自动重取

这是一份“可能尚无 conversationId 的服务端快照”,useQuery 能直接表达条件启用。若改成 useSuspenseQuery,应由父组件在 ID 存在后才渲染查询组件,而不是保留 enabled

当前 Active Turn 的 token、tool call、交互中断来自持续事件流,它不是“一个最终 Promise resolve 后得到的快照”。这部分继续由 useSSEProcessor、Reducer 与 Context 处理更自然:

历史会话 / 可重取快照 -> TanStack Query
当前流式增量事件       -> SSE + reducer
页面块初次加载等待     -> 可评估 Suspense

常见错误

  • 认为 Suspense 自动预取数据或消除请求瀑布。
  • useSuspenseQuery 上照抄 enabledplaceholderData 或可配置 throwOnError
  • 把一个无限增量 SSE 流当成一次会完成的普通 Query。
  • 没有 Error Boundary 和错误重置路径。
  • 用过高的 Boundary 让局部请求阻塞整页。
  • 忽略官方注明的取消限制。

从阅读到独立设计

  1. 预测:把 useConsultationSessionQuery 原样改成 useSuspenseQuery,哪两个选项首先失效?
  2. 模仿:在父组件判断 conversationId,仅在非空时渲染 Suspense 查询子组件。
  3. 重建:不看示例,为“用户资料卡”设计 Query、Suspense、Error Boundary 与重试结构。
  4. 迁移:解释为什么咨询历史适合 Query,而正在追加的 token 不应直接套同一个模型。
创建于 2026/7/3 更新于 2026/8/1