AI 参与说明(Agent:Codex):本文由 Codex 根据 Convex、TanStack Query、React 的官方文档及公开源码辅助整理,重点核对实时订阅、Suspense、SSR、条件查询与缓存生命周期的边界。资料核验于 2026-08-12;
@convex-dev/react-query仍是 beta,TanStack Start 仍处于 Release Candidate,实施前请以项目 lockfile、目标版本和文末一手资料为准。
适用范围:本文讨论 React、TanStack Query v5 与
@convex-dev/react-queryadapter 的组合。文中的useQuery与useSuspenseQuery默认都从@tanstack/react-query导入;它们不是convex/react的同名原生 Hook。示例假设项目已经有可调用的 public Convex query,并以api.projects.get为占位名称;替换为自己的 generated API、参数与权限模型后再运行。
示例运行说明:文中的
ProjectView、ProjectSkeleton、MessagesRoute等是应用自己的 UI 组件,未在本文重复实现。要复现行为,需要让api.projects.get接受{ projectId: v.id("projects") }并返回项目 document 或null,设置 deployment URL,运行npx convex dev生成 API,再把片段放进已完成 Provider 配置的 React 应用。预期行为是:"skip"不建立订阅;冷 cache 下常规 Hook 显示局部 loading,而 Suspense Hook 显示最近 boundary 的 fallback;相关 Convex mutation 后两者都会收到推送更新。
先给结论:先判断“谁负责缺数据时的 UI”
在 Convex + TanStack Query 的组合里,useQuery 与 useSuspenseQuery 不是两种实时性等级,也不是“一个缓存、一个不缓存”。对于同一个 convexQuery(api.foo.bar, args),它们使用同一套 Convex subscription:数据库相关数据改变后,Convex 会把新结果推到客户端,adapter 再更新 TanStack Query cache。二者的核心差异是:数据暂时不可用时,组件继续渲染并自行处理状态,还是让 React Suspense 暂停该子树并由 boundary 接管。
可以先用下面四个问题判断:
- 没有最终数据,这块 UI 还能否正确、有价值地显示? 能,例如筛选器、页面框架、侧栏或可选卡片:优先
useQuery。不能,例如详情主体、会话正文:可考虑useSuspenseQuery。 - 参数和身份是否已经确定?
projectId、搜索词、认证 token、tab 是否打开等任一项还未就绪:优先useQuery,用"skip"条件订阅;或在外层先 gate,等确定后再挂载 Suspense 子组件。 - 是否有恰当的
<Suspense>和<ErrorBoundary>? 没有明确的加载/错误边界设计时,不要仅为了data类型不再带undefined而改用useSuspenseQuery。 - 是否在 TanStack Start 做首屏 SSR,且这份数据必须进入初始 HTML? 是:
useSuspenseQuery是 Convex 官方给出的直接路径;关键数据也可由 route loader 的ensureQueryData提前取得。
一句口诀:
能局部降级、需要条件订阅、需要保留旧内容时用
useQuery;数据是组件成立的前提、输入已确定、边界清晰且 SSR 流程已验证时用useSuspenseQuery。多个彼此独立且都必须的数据,使用useSuspenseQueries或提前预取,不要连续写多个useSuspenseQuery。
1. 先消除三个同名 API 的混淆
“Convex + TanStack Query”至少涉及三组 API。没有先区分它们,很容易把文档和代码混在一起:
// 1. Convex 原生 React Hook:不经过 TanStack Query。
import { useQuery as useConvexQuery } from "convex/react";
// 2. 本文比较的两个 Hook:由 TanStack Query 提供。
import {
useQuery as useTanStackQuery,
useSuspenseQuery,
} from "@tanstack/react-query";
// 3. adapter:把一个 Convex public query 变成 TanStack Query options。
import { convexQuery } from "@convex-dev/react-query";
convex/react 的原生 useQuery 在首次加载时返回 undefined,之后返回 query 结果;它会自动建立并维护订阅。它本身不会因为被包在 <Suspense> 中就抛出 Promise,因此不会自动激活 Suspense fallback。Convex React
useSuspenseQuery 则是 TanStack Query v5 的专用 Suspense Hook。要让它订阅 Convex query,需要 @convex-dev/react-query 的 ConvexQueryClient 与 convexQuery(...);不要把它当成 Convex 原生 API。Convex with TanStack Query TanStack useSuspenseQuery
如果项目没有 TanStack Router/Start、TanStack Devtools、统一管理外部 HTTP query 等明确需求,直接使用 convex/react 原生 useQuery 往往已经足够:它已有订阅、自动更新和客户端一致性。引入 adapter 的理由应是 TanStack 的路由、SSR/hydration、Devtools 或统一状态模型,而不是希望把 Convex 数据“变得更实时”。
2. Adapter 的原理:TanStack cache 是订阅的投影层
下面这张图描述的是客户端的概念流程;不是另一套数据库同步协议:
flowchart TD
A["React component"] --> B{"TanStack Hook"}
B --> C["QueryClient cache\nquery key / observer / gcTime"]
C --> D["ConvexQueryClient"]
D --> E["ConvexReactClient watch subscription\nWebSocket"]
E --> F["Convex public query"]
F --> G["Convex database transaction state"]
G --> F
F -->|"initial result / changed result"| E
E -->|"push result"| D
D -->|"setQueryData"| C
C --> B
ConvexQueryClient 监听 TanStack QueryClient 的 query 生命周期,为 Convex query 建立 WebSocket subscription,并把服务器推送的结果写回 cache。convexQuery() 负责构造兼容的 query key 和 options;它会把 staleTime 设为 Infinity。adapter 介绍 ConvexQueryClient API
这带来几个与普通 fetch query 不同的后果:
- 对 reactive
convexQuery,结果不是靠轮询“过期后再取”;相关数据变更时服务器主动推送,因此isStale始终为false。 - 对这类 query,
retry、refetchOnWindowFocus、refetchInterval、写后invalidateQueries()不应被当作数据新鲜度方案。Convex 负责连接重试和推送,mutation 后相关 query 会自动得到新结果。 gcTime不只是内存回收时间:最后一个 observer 卸载后,adapter 仍会保留 subscription 直到gcTime到期。默认是 5 分钟;短一些能减少离屏 query 活动,长一些能让页面切换回来时立即有最新数据。- 这个结论只适用于
convexQuery这种 reactive query。外部 HTTP query 仍遵循普通 TanStack Query 规则;convexAction()也不是 reactive query,应按它自己的 refetch/失效需求设计。
因此,TanStack cache 在这里是渲染状态、生命周期、预取与 SSR 的适配层,不是需要人工维护为“第二份真相”的缓存。 不要把 REST 项目里“mutation 成功后必定 invalidateQueries”的习惯原样搬过来。与 fetch-based query 的差异
3. 正确接入:两个 Provider、一个 Convex client
先安装 adapter。若项目已经安装 convex 与 @tanstack/react-query,npm 会复用已有依赖版本;应将三者固定在 lockfile 中。
npm install convex @tanstack/react-query @convex-dev/react-query
以下是纯 CSR 入口的最小配置:
// src/main.tsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ConvexQueryClient } from "@convex-dev/react-query";
import { ConvexProvider, ConvexReactClient } from "convex/react";
import { App } from "./App";
const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL);
const convexQueryClient = new ConvexQueryClient(convex);
const queryClient = new QueryClient({
defaultOptions: {
queries: {
queryKeyHashFn: convexQueryClient.hashFn(),
queryFn: convexQueryClient.queryFn(),
},
},
});
// 对同一个 QueryClient 只连接一次。
convexQueryClient.connect(queryClient);
createRoot(document.getElementById("root")!).render(
<StrictMode>
<ConvexProvider client={convex}>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</ConvexProvider>
</StrictMode>,
);
必须把同一个 ConvexReactClient 同时交给 ConvexProvider 和 ConvexQueryClient。这样,必要时仍可使用原生 Convex Hook(例如某些 pagination API),且整个应用只有一份客户端连接上下文。connect() 必须只执行一次;重复连接会报错。官方 setup API 约束
上例仅适用于浏览器入口。SSR 框架不能把这个 QueryClient 作为跨请求的模块级单例;应按框架的 server/client provider 生命周期创建它。TanStack Start 的处理方式见后文。
也不要为了接入 TanStack 自己写下面的 queryFn:它是一次性读取,不会得到 adapter 建立的持续 subscription。
// 不要用它替代 convexQuery();这里只会得到一次性结果。
queryFn: () => convex.query(api.projects.get, { projectId })
4. useQuery 与 useSuspenseQuery:同一数据源,不同渲染契约
| 维度 | useQuery(convexQuery(...)) | useSuspenseQuery(convexQuery(...)) |
|---|---|---|
| 冷启动、没有数据 | 正常返回 result object;组件自行看 data、isPending、isError | render 阶段 suspend,由最近 <Suspense fallback> 接管 |
data 类型 | 通常是 TData | undefined | TData,保证已定义 |
| Loading 的归属 | 组件内部可做局部 skeleton、禁用按钮、渐进显示 | boundary 统一决定整块区域的 fallback |
| Error 的归属 | 可就地显示 error,或通过 throwOnError 交给边界 | 初始无数据的 error 会到 ErrorBoundary;需要设计重试边界 |
| 条件查询 | 支持;adapter 可用 convexQuery(fn, "skip") | 不支持 enabled、placeholderData 或 skipToken |
| key 改变时保留旧内容 | 可以用 placeholderData 或显式局部 UI | 没有 placeholderData;若新 key 冷缓存,通常会再次触发 fallback |
| 同组件多个独立 query | 多个 Hook 可在同一次 render 中并行启动 | 连续多个 Hook 容易串行;改用 useSuspenseQueries、预取或拆分 boundary |
| TanStack Start 初始 SSR | 官方组合中不会因它主动拉取 Convex 数据 | 会在初始 SSR pass 发起获取,可直接把数据渲染进 HTML |
| 实时性、一致性 | 相同 | 相同 |
useSuspenseQuery 的 data 能省掉 undefined 分支,并不是因为服务器永远成功:当数据还没到时,组件的本次 render 不会继续执行,而是由 <Suspense> 显示 fallback;有初始 error 时,则由 ErrorBoundary 接手。TanStack 文档明确列出它不支持 enabled、placeholderData、throwOnError 选项,且 cancellation 不工作。useSuspenseQuery reference Suspense guide
5. 优先 useQuery 的场景
5.1 参数、身份或显示条件尚未就绪
这是最常见也最明确的选择。比如 URL 或父级状态尚未给出 projectId,用户没有选择 tab,认证仍在解析,或者搜索词太短。不要条件调用 Hook;始终调用 useQuery,只把 args 切换为 adapter 支持的 "skip"。
import { useQuery } from "@tanstack/react-query";
import { convexQuery } from "@convex-dev/react-query";
import type { Id } from "../convex/_generated/dataModel";
import { api } from "../convex/_generated/api";
type Props = {
projectId?: Id<"projects">;
};
export function ProjectPanel({ projectId }: Props) {
const projectQuery = useQuery(
convexQuery(
api.projects.get,
projectId ? { projectId } : "skip",
),
);
if (!projectId) return <p>请选择一个项目。</p>;
if (projectQuery.isError) {
return <p>加载失败:{projectQuery.error.message}</p>;
}
// 对已启用的 Convex query,undefined 表示结果尚未可用。
if (projectQuery.data === undefined) return <ProjectSkeleton />;
// 此分支假设 api.projects.get 用 db.get(),因而用 null 表示记录不存在。
if (projectQuery.data === null) return <p>项目不存在或无权访问。</p>;
return <ProjectView project={projectQuery.data} />;
}
这里应特别区分 undefined 和业务值。对原生 Convex useQuery,undefined 也是首次加载与 "skip" 的返回值;因此 Convex query 的领域层最好用 null 或显式状态对象表达“未找到”,不要把 undefined 作为正常业务结果。Convex React 的 loading/skip 语义
"skip" 时不会创建 subscription,也不会发请求。若它没有 cache,TanStack result 可能仍是 pending,但并不代表正在网络加载;UI 应先依据 projectId 或自身的条件显示“请选择/尚未准备好”,而不是只看 isPending 就转圈。
5.2 页面要渐进显示,而不是整体挡住
例如项目页的导航、标题、编辑表单已经能出现,但“活动统计”“推荐成员”“审计日志”只是增强信息。让每张卡使用常规 useQuery,可各自展示局部 skeleton 或错误,而不会因为一个慢 query 把整个页面换成 fallback。
这也是搜索、可选抽屉、tooltip、按需 tab 的合理默认选择。它们常常有高频 key 变化或不一定会被打开,优先控制是否订阅、何时展示 loading、何时保留旧结果。对昂贵的 query,再根据实际导航频率把 gcTime 调低,而不是试图用 refetchInterval 控制 Convex 新鲜度。
5.3 切换 key 时,旧内容短暂保留比 fallback 更自然
分页、非敏感筛选和列表排序经常希望保留上一页内容并显示“正在更新”。useQuery 能使用 placeholderData 或自己的局部 loading 方案;useSuspenseQuery 没有 placeholderData。
但“保留旧数据”是产品决策,不是性能默认项:在项目、租户、账户、权限范围切换时,展示旧主体可能误导甚至泄露信息。此类路径应清空、使用明确 fallback 或为 boundary 设置与身份范围一致的 key。
5.4 同一组件有多个相互独立的读取
当 teamId 已知时,团队详情和成员列表可以并行启动:
const team = useQuery(convexQuery(api.teams.get, { teamId }));
const members = useQuery(convexQuery(api.members.list, { teamId }));
组件可以先显示 team,成员区继续 loading。若用户体验允许分块出现,这个写法直接且高效。
6. 优先 useSuspenseQuery 的场景
6.1 数据是该组件成立的前提
如果一个组件没有项目详情就无法解释自己在展示什么,或一个对话正文没有消息就不能正确渲染,把它放在明确的 Suspense boundary 内会更贴合 UI 语义。父组件先处理可选参数,子组件只接收已确定的参数:
import { Suspense } from "react";
import {
QueryErrorResetBoundary,
useSuspenseQuery,
} from "@tanstack/react-query";
import { ErrorBoundary } from "react-error-boundary";
import { convexQuery } from "@convex-dev/react-query";
import type { Id } from "../convex/_generated/dataModel";
import { api } from "../convex/_generated/api";
function RequiredProject({ projectId }: { projectId: Id<"projects"> }) {
const { data: project, error, isFetching } = useSuspenseQuery(
convexQuery(api.projects.get, { projectId }),
);
// 可选策略:把“已有旧数据后的最终错误”也交给 ErrorBoundary。
if (error && !isFetching) throw error;
if (project === null) return <p>项目不存在或无权访问。</p>;
return <ProjectView project={project} />;
}
export function ProjectScreen({
projectId,
}: {
projectId?: Id<"projects">;
}) {
if (!projectId) return <p>请选择一个项目。</p>;
return (
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary
onReset={reset}
fallbackRender={({ error, resetErrorBoundary }) => (
<button onClick={resetErrorBoundary}>
重试:{error.message}
</button>
)}
>
<Suspense fallback={<ProjectSkeleton />}>
<RequiredProject projectId={projectId} />
</Suspense>
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
);
}
上例的 ErrorBoundary 来自额外依赖 react-error-boundary,可先安装:
npm install react-error-boundary
最重要的不是这个包,而是边界归属:RequiredProject 内的参数永远存在,loading 由 <Suspense> 承担,错误有可点击的重试路径。不要写 enabled: !!projectId 来绕过这个设计;useSuspenseQuery 不支持 enabled。参数尚未确定时,要么保留在外层,要么改用常规 useQuery。
6.2 有完整的 TanStack Start + Convex SSR 方案
这是 useSuspenseQuery 最有说服力的场景。Convex 官方明确说明:在 TanStack Start 的初始 SSR pass 中,useSuspenseQuery(convexQuery(...)) 会发起 Convex 数据获取,而普通 useQuery 不会。浏览器接管后,Convex client 会从 SSR 的结果继续实时 subscription,避免首屏 loading flash 和重复取数;同页 query 还能使用同一逻辑时间戳,降低服务端多次读取出现不一致视图的风险。Convex + TanStack Start
页面主体的必要数据可以在组件内 co-locate,也可以在路由 loader 中提前阻塞:
// 这是 TanStack Start route 的结构示意。
// 请替换 api.messages.list 为自己的 public query。
import { createFileRoute } from "@tanstack/react-router";
import { convexQuery } from "@convex-dev/react-query";
import { api } from "../convex/_generated/api";
export const Route = createFileRoute("/messages")({
loader: async ({ context }) => {
await context.queryClient.ensureQueryData(
convexQuery(api.messages.list, {}),
);
},
component: MessagesRoute,
});
ensureQueryData 适合“没有数据就不能渲染该路由主体”。如果数据只是尽早拿到更好,但页面可先显示,loader 应调用但不 await prefetchQuery(...),组件再使用普通 useQuery 处理局部 loading。Convex 的 loader 示例
6.3 已经设计好一组一起出现的 loading sequence
React 的 Suspense boundary 是产品 UI 的分段工具:它决定“哪些内容一起等待、哪些内容可以渐进出现”。不要给每个很小的组件机械套一层 boundary;应按设计稿中希望用户一起看到的区域划分。React 也说明,当已显示的内容因 key 改变再次 suspend 时,若更新不在 startTransition 或 useDeferredValue 中,fallback 会重新替换它。React <Suspense>
例如切换非敏感项目时,若希望保留已显示的内容直到下一个项目可展示,可将改 key 的 state update 放进 transition:
import { useTransition } from "react";
const [isPending, startTransition] = useTransition();
function selectProject(nextProjectId: Id<"projects">) {
startTransition(() => {
setProjectId(nextProjectId);
});
}
这只是在改善 UI 连续性,不会改变 Convex 的权限或一致性语义;涉及账户/租户切换时,仍应优先保证不会继续展示错误范围的数据。
7. Suspense 最容易踩的两个坑
7.1 多个独立 query 写成连续的 useSuspenseQuery
冷 cache 下,第一处 useSuspenseQuery 会 suspend 当前 render,后面的 Hook 来不及执行;等第一个完成、React 重试 render 后,第二个才开始。这会形成请求 waterfall:
// 冷 cache 时可能串行,不推荐。
const team = useSuspenseQuery(convexQuery(api.teams.get, { teamId }));
const members = useSuspenseQuery(convexQuery(api.members.list, { teamId }));
如果两者相互独立且参数都准备好了,用 useSuspenseQueries 一起启动:
import { useSuspenseQueries } from "@tanstack/react-query";
const [{ data: team }, { data: members }] = useSuspenseQueries({
queries: [
convexQuery(api.teams.get, { teamId }),
convexQuery(api.members.list, { teamId }),
],
});
另一种办法是在 route loader、hover 或导航事件预取,或拆成具有各自 boundary 的独立组件。TanStack 官方也提醒:useSuspenseQueries 同样不支持每项 enabled / placeholderData,并且 cancellation 不工作。Parallel Queries useSuspenseQueries reference
若第二个 query 的参数必须等待第一个结果才得到,瀑布是业务数据依赖,不是 Hook 的 bug。优先考虑把前端真正需要的组合数据放进一个 Convex query,或在已知参数时提前预取;仅换成 Suspense 不会消除这个因果顺序。
7.2 把 useSuspenseQuery 当成可取消的条件请求
useSuspenseQuery 没有 cancellation 支持;而 Convex adapter 的实时 lifecycle 还会受 gcTime 控制。不要依据“我希望中止请求”来选它或普通 useQuery。
对于 Convex reactive query,更可靠的控制顺序是:
- 不满足条件时用
convexQuery(fn, "skip"),根本不创建 subscription; - 参数稳定后再开启普通
useQuery,或挂载接受确定参数的 Suspense 子组件; - 根据页面来回切换频率与 query 成本设置
gcTime; - 对 typeahead 之类高频输入,先 debounce / defer 输入值,再决定是否创建 subscription。
这比依赖 cancelQueries() 更贴近 Convex 的推送模型。
8. SSR 不是一个开关:按框架分开判断
| 运行方式 | 推荐判断 |
|---|---|
| 纯 CSR | 两个 Hook 没有 SSR 差异;按 loading boundary、参数确定性和 UI 连续性选择。没有强理由时,useQuery 是更保守的默认。 |
| TanStack Start + adapter | 对要进入初始 HTML 的必需数据,优先 useSuspenseQuery 或 loader ensureQueryData;可选内容用不 await 的预取 + useQuery。这是 Convex 官方明确说明的组合。 |
| 通用 TanStack Query SSR/hydration | 不要因为使用了 useSuspenseQuery 就假定服务器会安全预取和 hydrate。TanStack 文档建议:只有完整预取/dehydrate/hydrate 或受支持 streaming integration 已验证时才采用,否则可能重复取数或出现 hydration 不一致。 |
| Next.js App Router(未特意采用 TanStack Start 流程) | Convex 官方提供另一条原生路径:Server Component 用 preloadQuery,Client Component 用 usePreloadedQuery,初屏 SSR 后继续实时更新。不要把 TanStack Start 的结论直接套到所有 Next.js 页面。 |
Next.js 的原生预加载路径目前也是 beta,且服务端认证需要显式传递 token;是否选它取决于项目是否真的需要 TanStack Query 作为这条读模型的 owner。Convex Next.js Server Rendering
9. 一张场景决策表
| 场景 | 首选 | 原因与替代方案 |
|---|---|---|
| 简单纯 React 客户端,只有 Convex 数据 | 原生 convex/react 的 useQuery | Convex 已提供订阅与一致更新;无需为了“缓存”额外叠 adapter。 |
| 已全面采用 TanStack Query,需要统一 Devtools/Router/外部 API | useQuery(convexQuery(...)) 起步 | 先保持局部 loading/error,逐块引入 Suspense。 |
| route 参数、认证、tab、搜索词未就绪 | useQuery + "skip" | 不创建无效 subscription;Suspense 子组件只应接收确定参数。 |
| 项目详情、会话正文等“没有数据就无页面主体” | useSuspenseQuery | 给这块主体配置相称的 <Suspense> + ErrorBoundary。 |
| dashboard 有多个可独立展示的卡片 | 多个 useQuery | 可并行、可局部失败、可渐进显示。 |
| 一组独立数据必须整块出现 | useSuspenseQueries 或 loader 预取 | 避免连续 useSuspenseQuery 造成 waterfall。 |
| 分页/筛选切换,希望旧数据暂留 | useQuery | 使用 placeholderData 或自定义过渡 UI;先评估旧数据是否会误导。 |
| TanStack Start 关键首屏 SSR | useSuspenseQuery / ensureQueryData | 官方 adapter 能在 SSR 获取并在浏览器接续 subscription。 |
| 登录态正在初始化 | 外层 auth gate 后再挂载,或 useQuery + "skip" | 不要让必须认证的 Suspense query 先以错误身份运行。 |
| Convex cursor pagination | 视能力使用原生 usePaginatedQuery | adapter 不是原生 React client 全部 API 的替代;可以在同一 Convex client 下并用。 |
10. 发布前如何验证自己的选择
不必先争论“哪个 Hook 更现代”,可以按下面的用例验证实际行为:
- 冷启动: 清除浏览器 cache/重新打开页面,确认
useQuery只替换预期的局部区域,useSuspenseQuery只触发设计好的 fallback。 - 条件: 在
projectId、登录态或 tab 未准备好时,确认 adapter 没有创建 subscription;条件满足后再开始读取。 - 实时更新: 在另一标签页执行 Convex mutation,确认当前 query 自动更新;不要为了这个测试再加入
invalidateQueries()。 - 切换 key: 快速换项目、筛选或搜索词,确认是否出现不希望的 fallback、旧数据误导或无意义的离屏订阅;据此调整
startTransition、placeholderData、"skip"与gcTime。 - 并行: 在网络 throttling 下观察多个关键 query 是否串行;必要时改
useSuspenseQueries、route loader 或后端聚合 query。 - SSR: 若用 TanStack Start,分别检查初始 HTML、hydration 后订阅以及认证路径;若是其他 SSR 框架,先验证预取/hydration 链路,再启用 Suspense。
结语
最终的选择并不复杂:先问组件在没有数据时能否诚实而有用地存在,再问参数是否已经确定,最后问边界和 SSR 是否真的被设计与验证过。
useQuery 让组件自己表达 loading、error、条件与渐进显示;useSuspenseQuery 把“没有数据就不能显示”提升为 React 树的边界协议。Convex 为两者提供同一套实时推送与一致视图,TanStack Query 则提供 lifecycle、预取、SSR/hydration 和 UI 协调能力。理解这个职责划分后,Hook 的选择会从风格偏好变成可解释的产品和架构决策。
相关阅读与一手资料
- 站内基础阅读:TanStack Query v5 基础:从 Server State 到 Query、Mutation 与缓存。历史 v4+ 参考见:全面掌握 TanStack Query:现代 React 应用的数据管理利器。
- 站内源码背景:Convex 源码导读:开源仓库版图、核心架构与阅读路线
- Convex with TanStack Query · Convex + TanStack Start · Convex React
- TanStack
useSuspenseQuery· TanStack Suspense guide · Request Waterfalls - React
<Suspense>·@convex-dev/react-querysource