本节目标:先分清「服务端状态」和「客户端状态」这两种完全不同的东西,再掌握 TanStack Query 的类型推导链路——从
queryKey到queryFn返回值,再到组件里的data。读完本节,你应该能写出一个类型完全闭环、错误分支被强制处理的数据获取层,并且知道哪些地方必须显式标注类型、哪些地方交给推导就好。
14.1 TanStack Query 类型推导
先看一段几乎人人都写过的组件:
function UserCard({ id }: { id: string }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let alive = true;
setLoading(true);
fetch(`/api/users/${id}`)
.then((r) => r.json())
.then((d) => alive && setUser(d))
.catch((e) => alive && setError(String(e)))
.finally(() => alive && setLoading(false));
return () => {
alive = false;
};
}, [id]);
return loading ? <Spinner /> : error ? <Err msg={error} /> : <Card user={user!} />;
}
这段代码没有语法错误,pnpm typecheck 也是绿的。但 .json() 的返回类型是 any,所以 setUser(d) 这个赋值绕过了所有检查——后端把 nickname 改成 nick_name,编译期一无所知。而结尾那个 user! 非空断言,是类型系统在向你发出求救信号。
14.1.1 两种状态,两套工具
手写 useEffect 的问题不只是「类型丢了」,而是它把四件本该由框架负责的事塞给了每个组件:缓存、去重、重试、失效。要理解 TanStack Query 为什么值得引入,先要接受一个分类:
| 维度 | 客户端状态 | 服务端状态 |
|---|---|---|
| 数据归属 | 前端自己产生并持有 | 后端是唯一真相源 |
| 典型例子 | 弹窗开关、表单草稿、主题 | 用户列表、订单详情、配置 |
| 一致性要求 | 本地即时生效即可 | 随时可能被别人改掉 |
| 生命周期 | 随组件挂载卸载 | 跨页面、跨会话长期存在 |
| 合适的工具 | Zustand / Redux / Context | TanStack Query / SWR |
把两者混在一个 store 里,是前端状态管理最常见的架构错误:你会被迫手写 isStale、refetchOnFocus、invalidate 这些本不属于客户端状态的概念。客户端状态那部分见 《TypeScript编程实战》11.3 Context 与状态管理(Zustand / RTK)
,本节只谈右边一列。
14.1.2 useQuery 的四个类型参数
useQuery 的签名(简化)长这样:
declare function useQuery<
TQueryFnData = unknown,
TError = Error,
TData = TQueryFnData,
TQueryKey extends QueryKey = QueryKey,
>(options: UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>): UseQueryResult<TData, TError>;
四个参数的分工见 TanStack Query 类型文档
,其中错误类型不会由 throw 推导:
| 参数 | 含义 | 谁来提供 |
|---|---|---|
TQueryFnData | queryFn 的返回类型 | 从函数返回值推导 |
TError | 错误类型 | 默认 Error;显式参数或全局 Register 可改写,不能从 throw 推导 |
TData | select 之后的类型 | 从 select 返回值推导 |
TQueryKey | queryKey 的精确类型 | 从传入的 key 推导 |
关键在于:这四个参数几乎都不需要手写。只要 queryFn 有明确返回类型、queryKey 是字面量数组,推导就自然成立。下面是一个最小闭环:
interface User {
id: string;
name: string;
email: string;
}
async function fetchUser(id: string): Promise<User> {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json() as Promise<User>;
}
const { data } = useQuery({
queryKey: ['user', id],
queryFn: () => fetchUser(id),
});
// data: User | undefined
data 是 User | undefined,而不是 any——这就是引入 Query 最直接的收益。注意 undefined 不是多余的:首次加载、或者查询被禁用时,data 确实是空的。类型系统在这里没有撒谎。
顺带看两个派生字段的类型,它们比 data 更常用:
const q = useQuery({ queryKey: ['user', id], queryFn: () => fetchUser(id) });
q.isSuccess; // boolean(不是类型守卫)
q.isPending; // boolean,v5 里替代了 isLoading 的语义
q.status; // 'pending' | 'error' | 'success'
status 是判别联合,所以可以用 switch 精确窄化;而 isSuccess 只是 boolean,它不会帮你把 data 收窄成非空。这是 v5 用户最容易踩的一个直觉陷阱,正确写法是用 status 或 data !== undefined 判断。
14.1.3 queryKey 才是类型的源头
上面那段推导能成立,前提是 queryKey 是字面量数组。如果你把它抽成一个变量而不加 as const,就会出问题:
const key = ['user', id]; // string[]
const { data } = useQuery({ queryKey: key, queryFn: () => fetchUser(id) });
大多数情况下这仍然能跑,但 TQueryKey 会被推成 string[],你失去了「这个 key 只能配这个 queryFn」的约束力。工程上更可靠的做法是 key factory——把 key 的构造集中到一个对象里:
export const userKeys = {
all: ['users'] as const,
lists: () => [...userKeys.all, 'list'] as const,
list: (filter: UserFilter) => [...userKeys.lists(), filter] as const,
details: () => [...userKeys.all, 'detail'] as const,
detail: (id: string) => [...userKeys.details(), id] as const,
};
as const 是这里的灵魂:它把 ['users'] 从 string[] 收窄成 readonly ['users'],后面的 invalidateQueries({ queryKey: userKeys.all }) 才能按前缀精确匹配。这一点在 《TypeScript编程实战》14.2 乐观更新与缓存失效
会展开,现在只需要记住:key 的类型精度决定了失效的精度。
14.1.4 queryOptions:让配置可复用的官方姿势
v5 引入的 queryOptions 是本节最值得单独记一个 API。它把「key + queryFn」打包成一个普通对象,同时保留了全部类型信息:
import { queryOptions } from '@tanstack/react-query';
export const userDetailQuery = (id: string) =>
queryOptions({
queryKey: userKeys.detail(id),
queryFn: () => fetchUser(id),
staleTime: 30_000,
});
这个对象可以在三个地方复用,且类型完全一致:
// 1. 组件里
const { data } = useQuery(userDetailQuery(id));
// 2. 预取
await queryClient.prefetchQuery(userDetailQuery(id));
// 3. 缓存读写(key 自动对上)
queryClient.getQueryData(userDetailQuery(id).queryKey);
// ^? User | undefined
最后一行是 queryOptions 真正的价值:queryKey 与 queryFn 的类型被绑定在一起,getQueryData 能据此推导出缓存值的类型。手写 queryClient.getQueryData(['user', id]) 只会得到 unknown,你就不得不加断言——而断言正是我们想消灭的东西。
14.1.5 推导结果验证与 select 收窄
想知道推导到底给了什么类型,最省事的办法是让编译器自己回答:
const q = useQuery(userDetailQuery(id));
type R = typeof q;
// ^? type R = UseQueryResult<User, Error>
select 是把 TData 与 TQueryFnData 分开的那个参数。它接收完整的 User,返回你真正要用的形状:
const { data } = useQuery({
...userDetailQuery(id),
select: (user) => ({ label: user.name, value: user.id }),
});
// data: { label: string; value: string } | undefined
这里有三个必须知道的约束。第一,select 必须稳定引用,否则每次渲染都重算;通常抽成模块级函数或 useCallback。第二,select 只在缓存命中后执行,它不改变缓存里存的内容——缓存里永远是完整的 User。第三,select 一旦抛出,data 不会更新而 isError 会翻转,所以不要在 select 里做有副作用的转换。
14.1.6 并行查询与类型保持
一个页面要同时拉多个资源时,不要写多个 useQuery 再手动合并,用 useQueries:
const results = useQueries({
queries: ids.map((id) => userDetailQuery(id)),
});
// results: Array<UseQueryResult<User, Error>>
const users = results.flatMap((r) => (r.data ? [r.data] : []));
// users: User[]
useQueries 的返回类型是同构数组,所以 r.data 依旧是 User | undefined。如果你需要「每个查询返回不同结构」,可以给数组加 as const,此时返回类型会变成元组,逐项类型各自独立:
const [users, stats] = useQueries({
queries: [userListQuery(), statsQuery()] as const,
});
// users.data: User[] | undefined
// stats.data: Stats | undefined
这个 as const 的用法知道就好,日常更推荐把异构查询拆成两个 Hook,可读性更好。
14.1.7 把查询封进自定义 Hook
组件里堆 query 配置迟早会重复。标准做法是每个资源配一个 Hook,把类型暴露出去:
export function useUser(id: string) {
return useQuery({
...userDetailQuery(id),
enabled: id !== '',
});
}
export function useUserList(filter: UserFilter) {
return useQuery({
queryKey: userKeys.list(filter),
queryFn: () => fetchUsers(filter),
});
}
调用方拿到的是 UseQueryResult<User, Error>,不用重复声明泛型。如果你想让多个 Hook 共享同一套「错误已归一化」的约定,可以再包一层泛型工具:
function useTypedQuery<TData>(options: UseQueryOptions<TData, ApiError, TData, QueryKey>) {
return useQuery<TData, ApiError, TData, QueryKey>(options);
}
const { data, error } = useTypedQuery({
queryKey: userKeys.detail(id),
queryFn: () => fetchUser(id),
});
// error: ApiError | null
这一层的价值在于把 TError 从默认的 Error 换成你自己的 ApiError(比如 《TypeScript编程实战》3.1 Result/Either 与类型化错误
里那套判别联合)。TypeScript 不会自动知道你的 fetch 封装抛的是 ApiError,错误类型必须显式声明——这是本节能给的最实用的一条经验。
14.1.8 五个常见坑
一、error 默认是 Error,但运行时未必。 如果 queryFn 里写的是 throw 'boom',error 依然被标注成 Error,error.message 在运行时是 undefined。要么保证只抛 Error 实例,要么显式把 TError 声明成 unknown 并在使用处用类型守卫收窄:
function toMessage(e: unknown): string {
return e instanceof Error ? e.message : String(e);
}
二、useQuery 不能写在条件分支里。 它本质是 Hook,必须遵守调用顺序规则。条件查询要用 enabled 而不是 if:
useQuery({ ...userDetailQuery(id), enabled: !!id });
三、queryFn 返回 any 会让整条推导塌掉。 最常见的元凶是 res.json()。类型断言 as Promise<User> 不是最优解——更稳的是用 zod 在边界校验一次,让运行时与编译期同时可信:
const UserSchema = z.object({ id: z.string(), name: z.string(), email: z.string().email() });
async function fetchUser(id: string): Promise<User> {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new ApiError(res.status);
return UserSchema.parse(await res.json()); // 运行时校验 + 类型推导双保险
}
四、key 里别塞对象。 queryKey: ['user', { id, tab }] 看着方便,但对象在哈希时按结构序列化,字段顺序不同会被当成两个 key。要么用数组 ['user', id, tab],要么在 key factory 里手动拼成稳定字符串。
五、staleTime 默认为 0。 这意味着窗口重新聚焦就会重取,本地开发时看起来像「请求发了两遍」。这不是 bug,是默认策略;按资源的易变性分别设定,比全局调一个值更合理。
14.1.9 与全书其它章节的衔接
queryFn 里的请求封装通常会复用第 5 章的 HTTP 客户端约定,见 《TypeScript编程实战》5.1 HTTP 服务与路由(Fastify / Hono)
。当后端与前端在同一个 TypeScript 仓库时,更彻底的方案是让 queryFn 直接调用带类型的 RPC 客户端,见 《TypeScript编程实战》16.1 tRPC 端到端类型安全
。若你还在 Vue 技术栈,同一套 Query 的类型模型可以平移,见 《TypeScript编程实战》12.1 Vue 3 组合式 API 类型
。自定义 Hook 本身的类型写法可回看 《TypeScript编程实战》11.2 Hooks 类型与自定义 Hook
。
站内已有专题对这套模式做过单点深挖,可作为延伸阅读:React 状态管理实践 、前端状态管理指南 、TypeScript 缓存策略 。Hooks 本身的类型细节可读 React Hooks 完全指南 。
小结
本节的核心是一条推导链:queryKey 提供类型精度,queryFn 提供数据类型,select 提供派生类型,TError 必须手动声明。把这四件事摆正,data 就是 User | undefined 而不是 any,error 就能被强制收窄,组件里那个 user! 也就可以删掉了。
工程上最值得坚持的三条纪律:一是把 queryKey 收进 key factory 并用 as const 保住字面量类型,因为失效与预取的精度完全取决于它;二是用 queryOptions 把配置打包,让组件、预取、缓存读写共享同一份类型;三是把错误类型显式声明成自己的 ApiError 判别联合,别让默认的 Error 掩盖运行时真相。
查询只是起点。用户点下「保存」之后,缓存要怎么改、什么时候重新拉取、失败如何回滚,是下一节的主题——《TypeScript编程实战》14.2 乐观更新与缓存失效 。
阅读导航:上一节:13.3 复杂表单与动态字段 · 下一节:14.2 乐观更新与缓存失效 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。