深入解析 React Query 的 useQuery:签名重载、返回状态机与源码级实现
2026/9/10 3:56:19 网站建设 项目流程

深入解析 React Query 的 useQuery:签名重载、返回状态机与源码级实现

【免费下载链接】query🤖 Powerful asynchronous state management, server-state utilities and data fetching for the web. TS/JS, React Query, Solid Query, Svelte Query and Vue Query.项目地址: https://gitcode.com/GitHub_Trending/qu/query

useQuery是 TanStack Query 在 React 框架下的核心 Hook,它把一个"服务端状态"(异步数据请求、缓存、后台刷新)声明式地接入组件渲染。本文以 docs/framework/react/reference/functions/useQuery.md 为主骨架,完整讲解其三个重载签名、类型参数、返回的状态对象与initialData/select/enabled/skipToken/keepPreviousData等实战用法,并结合 useQuery.ts 与 useBaseQuery.ts 的源码,还原它从 Hook 到QueryObserver订阅的底层链路。读完后你能:看懂官方 API 文档的每个签名,写出类型安全的查询代码,并理解 disabled/依赖查询、缓存播种、分页占位等场景背后的设计动机。

useQuery 是什么:一个 Hook,三个重载签名

useQuery并非只有一个函数签名,而是通过 TypeScript 重载(overload)对外暴露三种调用形态。它们共享同一套运行时实现,区别在于类型层面对data的收窄程度

重载触发条件options 类型返回类型
第一个设置了initialDataDefinedInitialDataOptionsDefinedUseQueryResult
第二个未设置initialDataUndefinedInitialDataOptionsUseQueryResult
第三个一般通用形态UseQueryOptionsUseQueryResult

三个重载在源码 useQuery.ts 中按顺序声明:

function useQuery<TQueryFnData, TError, TData, TQueryKey>(options, queryClient?): DefinedUseQueryResult<TData, TError>; function useQuery<TQueryFnData, TError, TData, TQueryKey>(options, queryClient?): UseQueryResult<TData, TError>; function useQuery<TQueryFnData, TError, TData, TQueryKey>(options, queryClient?): UseQueryResult<TData, TError>;

第一个重载的 JSDoc 明确指出:当设置了initialData时编译器会选择该重载,因此其返回类型DefinedUseQueryResultdata永远不是undefined——即便后台 refetch 失败,已有数据仍会保留并伴随error一起呈现,status在类型层面永远不会落到pending,因为initialData保证数据从一开始就存在。其余两个重载返回UseQueryResultdatapending阶段可能为undefined。这一点贯穿整个 API 的体验设计:类型系统把你的数据确定性转化为可检查的编译期约束

类型参数:四个泛型各自代表什么

所有重载共享同样的四个泛型参数,默认值如下:

  • TQueryFnData=unknown:你的queryFn解析(resolve)出的原始数据类型。官方文档写法为unknown,而实现层实际默认TQueryFnData = unknown
  • TErrorqueryFn可能抛出的错误类型。文档标注的默认值是Error,在源码 useQuery.ts 中对应别名DefaultError(其解析结果即Error);
  • TData=TQueryFnDatadata在经过select之后的最终类型。当没有使用select时,它就等于TQueryFnData,见 UseQueryOptions 文档;
  • TQueryKeyextendsreadonly unknown[]=readonly unknown[]:你的queryKey的类型。文档中表述为extendsreadonly unknown[],它约束了查询键必须是一个只读元组/数组。

参数:options 与可选的 queryClient

每个重载都接收两个参数:

  • options:完整配置对象。三个重载分别接收DefinedInitialDataOptions(等价于"传入useQuery的一切,外加initialData")、UndefinedInitialDataOptionsUseQueryOptions。其中UseQueryOptionsUseBaseQueryOptions一致,只是移除了suspense——因为react-query根据你调用的是useQuery还是useSuspenseQuery来推导是否开启 suspense,而不是把它暴露为配置项。UseQueryOptions还继承了subscribed?: boolean属性(默认true),设为false可以取消该 observer 对查询缓存更新的订阅,见 UseQueryOptions 文档;
  • queryClient?:类型为QueryClient。传入自定义QueryClient时会使用它;否则使用**最近上下文(nearest context)**中的那一个。也就是说,默认情况下你无需手动传入——<QueryClientProvider>会把它注入上下文,useQuery内部通过useQueryClient(queryClient)自动获取。

返回值:query 状态机与派生布尔值

useQuery返回"当前 query 结果",其核心字段语义:

  • status:枚举'pending' | 'error' | 'success'。当没有缓存数据可显示时为pending;最后一次请求失败为error;当 query 有数据可显示时为success
  • isPending/isSuccess/isError:与status对应的派生布尔值,纯粹为了读写方便;
  • data:查询数据,未设置initialData时在pending阶段可能为undefined
  • error:最近一次请求失败的错误对象;
  • isFetching:是否有请求正在进行(包括后台刷新);
  • isLoadingisPlaceholderData等其它派生标志在具体场景中使用(详见下文用例)。

这种"核心枚举 + 派生布尔"的设计在 DefinedUseQueryResult 文档与 UseQueryResult 文档中都有直接说明——前者是useQuery设置initialData后(或useSuspenseQueryisPlaceholderData省略前)的返回类型,data绝不可能是undefined;后者等价于UseBaseQueryResult,即未设置initialData时的返回类型。

status做分支

最基本的写法是按status三态渲染。注意这里引入了isFetching,用来区分"首次加载"与"后台更新":

import { useQuery } from '@tanstack/react-query' function Posts() { const { status, data, error, isFetching } = useQuery({ queryKey: ['posts'], queryFn: fetchPosts, }) if (status === 'pending') return 'Loading...' if (status === 'error') return <span>Error: {error.message}</span> return ( <div> <ul> {data.map((post) => ( <li key={post.id}>{post.title}</li> ))} </ul> <div>{isFetching ? 'Background Updating...' : ' '}</div> </div> ) }

用布尔标志做分支

同一个查询,也可以用isPending/isError取代status——文档的原话是"pick whichever reads better to you"(选择对你读起来更顺的一种):

import { useQuery } from '@tanstack/react-query' function Posts() { const { isPending, isError, data, error } = useQuery({ queryKey: ['posts'], queryFn: fetchPosts, }) if (isPending) return 'Loading...' if (isError) return <span>Error: {error.message}</span> return ( <ul> {data.map((post) => <li key={post.id}>{post.title}</li>)} </ul> ) }

两种写法渲染结果一致,选哪种取决于团队风格与分支复杂度。

实战用例逐个拆解

官方文档按场景提供了六类高价值范例,下面逐一展开。示例中的fetchPosts/fetchPost为返回 Promise 的数据获取函数,对应queryFn

场景一:initialData—— 让data从类型上"绝不 undefined"

给 query 一个初始数据,useQuery就会自动选中"defined"重载,data的类型不再含undefined,即便后台 refetch 失败,列表也始终可见:

import { useQuery } from '@tanstack/react-query' function Posts() { // `data` 是 `Post[]`,永远不会是 `undefined`,得益于 `initialData`—— // 即使 refetch 失败,列表也会与错误提示一起保留在界面上。 const { data, isError, error } = useQuery({ queryKey: ['posts'], queryFn: fetchPosts, initialData: [], }) return ( <div> {isError ? <span>Error: {error.message}</span> : null} <ul> {data.map((post) => <li key={post.id}>{post.title}</li>)} </ul> </div> ) }

关于initialData的语义,UndefinedInitialDataOptions 文档给出了精确说明,值得仔细读:若 query 尚未被创建或缓存,该值会被写入 query 缓存作为初始数据;如果传入的是函数,该函数会在共享/根 query 初始化期间只被调用一次,且需要同步返回初始数据;初始数据默认被视为已过期(stale),除非设置了staleTime;并且initialData会持久化进缓存。注意它与下面placeholderData(仅占位、不入缓存)有本质区别。

场景二:select—— 从缓存数据中派生组件所需的数据

select用缓存值派生出组件真正需要的东西,而不改变缓存中实际存储的内容——缓存仍持有完整的Post[],但此处data的类型是一个number

import { useQuery } from '@tanstack/react-query' function PostCount() { const { data, isPending, isError, error } = useQuery({ queryKey: ['posts'], queryFn: fetchPosts, select: (posts) => posts.length, }) if (isPending) return 'Loading...' if (isError) return <span>Error: {error.message}</span> return <span>{data} posts</span> }

由于select的入参类型会推导进TData,第二个泛型参数的意义在这里体现得最直观:TData默认为TQueryFnData,一旦提供了selectdata的最终类型由select的返回值决定。

场景三:依赖查询(dependent queries)—— 用enabled控制开关

只有当postId被设置后 query 才启用。文档特别强调:这种情况下要用isLoading而不是isPending,以免 query 处于禁用状态时错误地显示 loading:

import { useQuery } from '@tanstack/react-query' function Post({ postId }: { postId: number | undefined }) { const { data, isLoading, isError, error } = useQuery({ queryKey: ['post', postId], queryFn: () => fetchPost(postId!), enabled: postId != null, }) if (postId == null) return 'Select a post' if (isLoading) return 'Loading...' if (isError) return <span>Error: {error.message}</span> return <h1>{data?.title}</h1> }

代码里出现了postId!的非空断言——这是因为enabled: falsequeryFn实际上不会被调用,但编译器无法从enabled推断这一点。下面skipToken就是为了消除这类非空断言而生。

场景四:skipToken—— 类型安全的禁用写法

把同一个依赖查询改写成类型安全版本:queryFn只在postId有定义时才真正传入函数,否则传skipTokenskipToken@tanstack/react-query顶层导出:

import { skipToken, useQuery } from '@tanstack/react-query' function Post({ postId }: { postId: number | undefined }) { const { data, isLoading, isError, error } = useQuery({ queryKey: ['post', postId], queryFn: postId != null ? () => fetchPost(postId) : skipToken, }) if (postId == null) return 'Select a post' if (isLoading) return 'Loading...' if (isError) return <span>Error: {error.message}</span> return <h1>{data?.title}</h1> }

文档同时提醒了一个关键约束:queryFnskipToken时,refetch不生效;如果你需要手动触发这个 query,请改用enabled: false的写法。

场景五:从已缓存的列表播种详情 query —— 跳过 loading

initialData函数 +useQueryClient从已有的['posts']缓存里找到对应文章作为详情页初始数据,从而在跳转时直接跳过加载态。这里正好体现了"initialData若为函数则只调用一次"的语义——它只在初始时执行查找:

import { useQuery, useQueryClient } from '@tanstack/react-query' function Post({ postId }: { postId: number }) { const queryClient = useQueryClient() const { data, isError, error } = useQuery({ queryKey: ['post', postId], queryFn: () => fetchPost(postId), initialData: () => queryClient .getQueryData<Array<Post>>(['posts']) ?.find((post) => post.id === postId), }) if (isError) return <span>Error: {error.message}</span> return <h1>{data?.title}</h1> }

场景六:分页 —— 用placeholderData: keepPreviousData保留上一页数据

分页时让旧页数据在下一页加载期间保持可见,并用isPlaceholderData禁用"下一页"按钮,避免用户基于占位数据连点:

import { keepPreviousData, useQuery } from '@tanstack/react-query' import { useState } from 'react' function Posts() { const [page, setPage] = useState(0) const { data, isPlaceholderData, isError, error } = useQuery({ queryKey: ['posts', page], queryFn: () => fetchPosts(page), placeholderData: keepPreviousData, }) if (isError) return <span>Error: {error.message}</span> return ( <div> <ul> {data?.map((post) => <li key={post.id}>{post.title}</li>)} </ul> <button disabled={isPlaceholderData} onClick={() => setPage((old) => old + 1)} > Next Page </button> </div> ) }

这里placeholderData与前面initialData的区别再次出现:占位数据不会写入缓存(因此data可能为undefined,需要通过data?.map兜底),它只影响当前渲染,并用isPlaceholderData布尔值把"当前显示的是占位数据"暴露给开发者。

源码走读:useQuery 的实现与订阅链路

抛开类型重载,useQuery的运行时实现极其简短——useQuery.ts 最后一行就是全部:

export function useQuery(options: UseQueryOptions, queryClient?: QueryClient) { return useBaseQuery(options, QueryObserver, queryClient) }

也就是说,useQuery只是把具体 observer 类型QueryObserver(来自@tanstack/query-core)注入通用基座useBaseQueryuseSuspenseQuery、preact-query 等其它框架实现也复用了同一套useBaseQuery逻辑,只是传入不同的 observer 与推导方式。

真正干活的是 useBaseQuery.ts,其关键链路如下:

  1. 获取 client 并做默认值合并useQueryClient(queryClient)拿到上下文中的QueryClient,随后client.defaultQueryOptions(options)把你在QueryClientProvider上配置的全局默认值(如默认staleTimeretry)与本次options合并;
  2. 按 hash 定位已存在的 queryclient.getQueryCache().get(defaultedOptions.queryHash)根据 hash 找到共享缓存中的 query,为后续错误边界重试与 suspense 判定做准备;
  3. 开发期告警:非生产环境下若没有queryFn且没有默认 query 函数,会打印"未提供 queryFn"的console.error提示;
  4. 乐观结果:通过_optimisticResults让结果在真正订阅前就进入乐观的 fetching 状态(isRestoring恢复中则标记为isRestoring);
  5. observer 单例React.useState(() => new Observer(client, defaultedOptions))惰性创建且只创建一次,observer 内部持有订阅回调与当前结果;
  6. 订阅useSyncExternalStore订阅 observer,observer.subscribe(notifyManager.batchCalls(onStoreChange))把 React 的变更通知批量调度;同时先调用observer.updateResult(),弥补"创建 observer 到真正订阅之间"可能遗漏的缓存更新;subscribed === false时跳过订阅,退化为noop
  7. 选项热更新React.useEffect中每次defaultedOptions变化都会observer.setOptions(defaultedOptions)——这正是依赖查询里queryKey/enabled变化能驱动重查的机制;
  8. suspense 与错误边界shouldSuspend为真时throw fetchOptimistic(...)交给上层 Suspense;throwOnError/suspense命中时抛出result.error交给 Error Boundary;
  9. 属性级依赖追踪:当没有显式设置notifyOnChangeProps时,返回observer.trackResult(result),它按组件实际读取的属性做细粒度追踪,避免不必要的重渲染;显式设置了notifyOnChangeProps则直接返回结果。

从源码结构还可以推断出两个工程细节:文件顶部声明了'use client',保证该模块在 React Server Components / Next.js App Router 中作为客户端组件边界使用;同时useBaseQuery开发期的参数校验明确抛错——从 v5 起只允许"单个对象"调用形式useQuery({ queryKey, queryFn })),任何把参数拆开传的老写法都会在非生产环境抛出Bad argument type异常,这是向 v5 迁移时最常见的报错之一。

与 queryOptions、useQueryClient 的协同

在官方文档每个重载的 "See also" 中都固定指向同一个建议:使用queryOptions把这些配置useQuery与命令式 API(如queryClient.query)之间共享。它的价值在于让类型在声明点就固化下来,而不是在使用点各自推导——比如把一组queryKey/queryFn/staleTime定义成常量后,既能在组件里useQuery(queryOptions),也能在事件回调或预取逻辑里调用queryClient.fetchQuery(queryOptions),两处的类型完全一致。

useQueryClient的协同则体现在"缓存播种"类场景(上文场景五):useQueryClient()返回离当前组件最近的 client,getQueryData按 key 读缓存。另外,如果你需要脱离最近上下文、在局部指定其它 client,直接给useQuery传第二个参数queryClient即可——文档对它的注释是"Use this to use a customQueryClient. Otherwise, the one from the nearest context will be used."(用它来指定自定义QueryClient,否则使用最近上下文中的那个)。

常见误区与最佳实践小结

把官方文档、类型说明与源码对照后,可归纳出几条高价值的实践结论:

  1. v5 只接受对象参数:源码 useBaseQuery.ts 在非生产环境对非对象/数组参数直接抛Bad argument type,迁移老代码务必改为单对象形式;
  2. initialDataplaceholderData不要混淆:前者写缓存、函数只执行一次、默认视为 stale、且触发DefinedUseQueryResult类型收窄(dataundefined);后者不写缓存、是渲染层占位,用isPlaceholderData标记(参见 UndefinedInitialDataOptions 文档);
  3. 禁用态查询用isLoading判断enabled: falseskipToken的查询不会真正加载,此时isPending仍为真,直接用isLoading可避免误渲染 loading;
  4. skipTokenrefetch无效:需要手动触发被禁用的查询时改回enabled: false写法;
  5. select不改缓存:派生计算发生在 observer 层,缓存里始终是TQueryFnData形态的完整数据,避免为了渲染把变形后的数据写回缓存;
  6. 返回类型的选择由你决定:想让data从类型上保证可用(无undefined),就显式提供initialData,让编译器自动切到DefinedUseQueryResult

参考路径速查

  • 本文主体文档:docs/framework/react/reference/functions/useQuery.md
  • 运行时实现:packages/react-query/src/useQuery.ts(重载声明见 useQuery.ts#L50-L289,实现见 useQuery.ts#L291-L293)
  • 通用基座:packages/react-query/src/useBaseQuery.ts
  • 类型文档:UseQueryOptions、DefinedInitialDataOptions、UndefinedInitialDataOptions、UseQueryResult、DefinedUseQueryResult
  • 相关 Hook:useQueryClient、useSuspenseQuery
  • 上手示例:docs/framework/react/quick-start.md 及 examples/react/basic、examples/react/pagination(对应keepPreviousData分页用例)

【免费下载链接】query🤖 Powerful asynchronous state management, server-state utilities and data fetching for the web. TS/JS, React Query, Solid Query, Svelte Query and Vue Query.项目地址: https://gitcode.com/GitHub_Trending/qu/query

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询