React 基础体系 · 第 45/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。
TanStack Query:缓存键、失效、乐观更新、分页和离线
TanStack Query 是用于管理“服务端状态”的客户端库。这里的服务端状态不是指数据一定由某台服务器生成,而是指:数据的权威副本在当前 React 组件树之外,客户端需要通过请求读取、缓存、同步、更新和恢复它。
它解决的问题与 React 本地状态不同:
useState适合控制弹窗、输入框、选中项等本地交互状态;- TanStack Query 适合管理请求中的数据、加载状态、错误、缓存、重新获取和 mutation;
- Redux 可以保存服务端数据,但缓存键、失效、请求竞态、重试和分页通常需要额外设计;
- React Router 负责路由匹配、导航和部分数据加载能力,不会自动替代 TanStack Query 的查询缓存模型。
本文以 TanStack Query v5 风格 API、React 19 和现代 TypeScript 为例。TanStack Query 的缓存和查询状态运行在客户端;服务端渲染、路由 loader、API 服务和数据库仍然属于应用自己的服务端边界。
一、先建立模型:查询、缓存和 mutation 分别是什么
1. 查询是一个带身份的数据读取过程
一个查询可以抽象为:
其中:
- 是
queryKey,表示这份数据“是谁”; - 是
queryFn,表示如何读取数据; - 是查询状态,例如
pending、error、success、是否正在后台刷新等。
例如:
['todos', { userId: 42, page: 1 }]
可以表示“用户 42 的第 1 页待办事项”。
2. 缓存不是全局变量,而是按查询键索引的数据表
可以把 Query Cache 简化为:
observers 表示当前有多少个 useQuery 等订阅者观察这条缓存记录。
因此,下列两个查询不是同一条缓存:
['todos', { page: 1 }]
['todos', { page: 2 }]
而下面两个查询通常会被视为同一条查询键:
['todos', { page: 1, status: 'open' }]
['todos', { status: 'open', page: 1 }]
TanStack Query v5 会对查询键进行确定性哈希。对象属性顺序通常不影响哈希,但数组元素顺序会影响哈希:
['todos', 1, 'open'] // 与下面不同
['todos', 'open', 1]
['todos', { page: 1, status: 'open' }]
// 通常与属性顺序交换后的对象相同
查询键中的值应当是可稳定序列化、能准确表达数据身份的值。不要把每次渲染都会变化的函数、DOM 节点或不稳定对象放进 key。
3. mutation 是改变服务端状态的操作
查询通常表示:
读取服务端数据
mutation 表示:
创建、修改或删除服务端数据
例如:
POST /api/todos
PATCH /api/todos/42
DELETE /api/todos/42
mutation 默认不会因为成功就自动修改所有相关查询缓存。原因是 TanStack Query 无法凭空知道:
- 哪些查询包含了这个实体;
- 服务端是否应用了权限、排序、聚合或其他业务规则;
- 修改后分页边界是否变化;
- 服务器返回的是完整对象还是局部结果。
因此,mutation 成功后必须明确选择:
- 直接写入已知的服务器结果;
- 让相关查询失效并重新获取;
- 进行乐观更新,然后在后台校正。
二、缓存键:正确性首先取决于“身份建模”
1. 缓存键不是请求 URL 的装饰物
缓存键的职责是回答:
两次查询返回的数据是否属于同一个逻辑资源?
例如搜索列表至少依赖关键词、页码和过滤条件:
type TodoFilters = {
status: 'all' | 'open' | 'done'
search: string
page: number
}
const key = ['todos', filters] as const
如果请求实际使用了 status,但 key 没有包含它:
// 错误示例
useQuery({
queryKey: ['todos', filters.page],
queryFn: () => fetchTodos(filters),
})
那么先查询 status=all,再查询 status=done 时,两个请求可能共享同一条缓存。结果是:
- 第一个请求将全部任务写入 key;
- 第二个组件认为 key 相同,先读到旧数据;
- UI 出现过滤条件与数据不一致;
- 后台请求可能又把结果覆盖,表现为“偶尔恢复”。
这不是性能问题,而是缓存身份错误。
2. 推荐使用分层 key
常见形式是:
['todos']
['todos', 'list', { status: 'open', page: 1 }]
['todos', 'detail', 42]
分层的好处是可以使用前缀匹配:
queryClient.invalidateQueries({
queryKey: ['todos'],
})
这会匹配所有以 ['todos'] 开头的查询,包括列表和详情。
也可以精确失效:
queryClient.invalidateQueries({
queryKey: ['todos', 'detail', 42],
exact: true,
})
exact: true 的意义是只匹配这条完整 key,而不是它的子查询。
3. 查询键必须覆盖所有影响结果的变量
假设 queryFn 使用如下变量:
const fetchTodos = ({
userId,
status,
page,
}: {
userId: number
status: string
page: number
}) => {
// ...
}
则至少应满足:
完整代码:
import { useQuery } from '@tanstack/react-query'
type Todo = {
id: number
title: string
completed: boolean
}
type TodoPage = {
items: Todo[]
page: number
totalPages: number
}
async function fetchTodos(
userId: number,
status: 'all' | 'open' | 'done',
page: number,
signal?: AbortSignal,
): Promise<TodoPage> {
const params = new URLSearchParams({
userId: String(userId),
status,
page: String(page),
})
const response = await fetch(`/api/todos?${params}`, { signal })
if (!response.ok) {
throw new Error(`请求失败:${response.status}`)
}
return response.json() as Promise<TodoPage>
}
function TodoList({
userId,
status,
page,
}: {
userId: number
status: 'all' | 'open' | 'done'
page: number
}) {
const query = useQuery({
queryKey: ['todos', 'list', { userId, status, page }],
queryFn: ({ signal }) => fetchTodos(userId, status, page, signal),
})
if (query.isPending) return <p>加载中……</p>
if (query.isError) return <p>错误:{query.error.message}</p>
return (
<ul>
{query.data.items.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
)
}
这里的 signal 来自 TanStack Query。把它传给 fetch 后,当查询被取消或参数快速变化时,底层请求才有机会真正停止。若忽略 signal,TanStack Query 可以停止等待这次查询,但网络请求本身可能仍会继续。
4. queryKey 与 queryFn 必须保持一致
错误的抽象通常长这样:
function useTodos(page: number) {
return useQuery({
queryKey: ['todos'],
queryFn: () => fetchTodos(1, 'all', page),
})
}
组件表面上请求不同页,但缓存键永远是 ['todos']。后请求会覆盖前请求,分页 UI 可能显示错误页。
正确方式:
function useTodos(page: number) {
return useQuery({
queryKey: ['todos', 'list', { page }],
queryFn: ({ signal }) => fetchTodos(1, 'all', page, signal),
})
}
三、缓存生命周期:新鲜、过期和回收不是同一件事
TanStack Query 至少需要区分三个概念:
1. staleTime:数据多久被视为新鲜
如果数据在 staleTime 内,组件挂载、窗口重新获得焦点等触发点通常不会因为“数据已过期”而主动重新获取。
const query = useQuery({
queryKey: ['profile', userId],
queryFn: fetchProfile,
staleTime: 60_000,
})
这里表示数据在成功写入缓存后 60 秒内视为 fresh。
staleTime 不会删除数据,也不会阻止手动 refetch()。它只影响“是否需要重新获取”的判断。
2. gcTime:无观察者的缓存保留多久
当一个查询没有任何活动观察者后,它会变成 inactive。TanStack Query 会在 gcTime 到期后回收该缓存。
const query = useQuery({
queryKey: ['profile', userId],
queryFn: fetchProfile,
gcTime: 10 * 60_000,
})
gcTime 过去不会让正在使用的查询消失。它针对的是 inactive cache。
默认值和具体行为可能随版本变化,生产代码应以当前安装版本文档和类型定义为准;不要把 gcTime 当作服务端数据的过期时间。
3. staleTime 与 gcTime 的关系
可以用时间线理解:
t0 请求成功,dataUpdatedAt = t0
|<------ staleTime ------>|
数据 fresh 数据 stale
仍可留在缓存中
组件卸载 ───────────────────────────────┐
|<--- gcTime --->|
缓存可被回收
因此:
- stale:数据可能需要刷新;
- inactive:当前没有组件观察它;
- garbage collected:缓存记录已被回收。
一个数据可以同时是 stale 和 active,也可以是 fresh 和 inactive。
四、失效:不是“删除缓存”,而是改变新鲜度并触发后续同步
1. invalidateQueries 做了什么
调用:
await queryClient.invalidateQueries({
queryKey: ['todos'],
})
通常会经历两个逻辑:
- 将匹配查询标记为 stale;
- 对当前正在被观察的匹配查询进行后台重新获取。
它不是简单的 removeQueries。已有数据通常仍可先用于渲染,然后等待新数据回来。
这使 UI 能够实现:
旧数据继续显示
+
后台获取最新数据
+
成功后原子替换缓存
2. invalidateQueries、refetchQueries 和 removeQueries 的区别
invalidateQueries
适合 mutation 成功后表示“这批数据可能过期”:
await queryClient.invalidateQueries({
queryKey: ['todos', 'list'],
})
refetchQueries
更直接地要求匹配查询重新请求:
await queryClient.refetchQueries({
queryKey: ['todos', 'list'],
})
它表达的是“现在重新请求”,而不仅是“标记为过期”。
removeQueries
删除缓存记录:
queryClient.removeQueries({
queryKey: ['todos', 'detail', todoId],
exact: true,
})
这不是普通 mutation 成功后的首选,因为删除会丢失可立即显示的旧数据。它更适合登出、切换租户、清理敏感数据等场景。
3. 活跃查询与非活跃查询
默认失效行为重点是当前活跃的查询。非活跃查询通常只被标记为 stale,等下次重新挂载或其他触发点再获取。
可以显式控制:
await queryClient.invalidateQueries({
queryKey: ['todos'],
refetchType: 'all',
})
常见 refetchType 语义包括:
'active':重新获取活跃查询;'inactive':重新获取非活跃查询;'all':两者都重新获取;'none':只标记失效,不立即重新获取。
大量列表查询使用 'all' 可能造成不必要的请求,应根据业务数据量和一致性要求选择。
五、mutation 成功后的三种缓存策略
1. 服务器返回完整对象:直接写入详情缓存
假设服务端返回更新后的完整任务:
type UpdateTodoInput = {
id: number
title: string
}
async function updateTodo(input: UpdateTodoInput): Promise<Todo> {
const response = await fetch(`/api/todos/${input.id}`, {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ title: input.title }),
})
if (!response.ok) {
throw new Error(`更新失败:${response.status}`)
}
return response.json() as Promise<Todo>
}
可以把结果写入详情缓存:
import { useMutation, useQueryClient } from '@tanstack/react-query'
function useUpdateTodo() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: updateTodo,
onSuccess: (updatedTodo) => {
queryClient.setQueryData(
['todos', 'detail', updatedTodo.id],
updatedTodo,
)
queryClient.invalidateQueries({
queryKey: ['todos', 'list'],
})
},
})
}
这里做了两件不同的事:
setQueryData:已知详情结果,立即更新详情缓存;invalidateQueries:列表可能涉及排序、分页和服务端计算,因此让列表重新同步。
2. 服务器返回结果不足:失效后重新获取
如果服务端只返回:
{ "ok": true }
客户端无法可靠地构造完整的新实体。这时不要假装拥有准确数据:
useMutation({
mutationFn: archiveTodo,
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: ['todos'],
})
},
})
onSuccess 返回 Promise 时,mutation 的后续状态可以等待这个 Promise。这样调用方可以区分:
- mutation 请求已成功;
- 相关列表已经完成一次同步。
3. 客户端能证明变更:使用 setQueryData
对简单字段更新,可以直接更新详情缓存:
queryClient.setQueryData<Todo>(
['todos', 'detail', todoId],
(oldTodo) => {
if (!oldTodo) return oldTodo
return {
...oldTodo,
title: newTitle,
}
},
)
必须保持不可变更新,不能直接修改 oldTodo:
// 错误
queryClient.setQueryData(['todos', 'detail', todoId], (old) => {
if (old) old.title = newTitle
return old
})
直接修改可能绕过引用变化判断,也会让调试和回滚更加困难。
六、乐观更新:先改变界面,再处理真实结果
乐观更新的前提是:
客户端能构造一个合理的临时结果,并且失败时能恢复原缓存。
完整过程是:
1. 一个可运行的待办切换示例
import {
useMutation,
useQueryClient,
} from '@tanstack/react-query'
async function setTodoCompleted(input: {
id: number
completed: boolean
}): Promise<Todo> {
const response = await fetch(`/api/todos/${input.id}`, {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ completed: input.completed }),
})
if (!response.ok) {
throw new Error(`保存失败:${response.status}`)
}
return response.json() as Promise<Todo>
}
function useToggleTodo() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: setTodoCompleted,
onMutate: async ({ id, completed }) => {
// 1. 停止同一资源正在进行的后台刷新,
// 避免旧响应覆盖即将写入的乐观结果。
await queryClient.cancelQueries({
queryKey: ['todos', 'detail', id],
exact: true,
})
// 2. 保存回滚前的快照。
const previousTodo = queryClient.getQueryData<Todo>([
'todos',
'detail',
id,
])
// 3. 写入乐观结果。
queryClient.setQueryData<Todo>(
['todos', 'detail', id],
(oldTodo) => {
if (!oldTodo) return oldTodo
return {
...oldTodo,
completed,
}
},
)
// 4. 返回给 onError / onSettled 的上下文。
return { previousTodo }
},
onError: (_error, variables, context) => {
if (!context?.previousTodo) return
queryClient.setQueryData(
['todos', 'detail', variables.id],
context.previousTodo,
)
},
onSuccess: (serverTodo) => {
// 服务器可能修改了 updatedAt、权限字段或其他派生字段。
queryClient.setQueryData(
['todos', 'detail', serverTodo.id],
serverTodo,
)
},
onSettled: (_data, _error, variables) => {
// 最终让客户端与服务器重新对齐。
return queryClient.invalidateQueries({
queryKey: ['todos', 'detail', variables.id],
exact: true,
})
},
})
}
2. 为什么必须先 cancelQueries
考虑以下时序:
t0 详情查询 A 发出,服务器数据为 completed=false
t1 用户点击,客户端乐观写入 completed=true
t2 查询 A 的旧响应才返回 completed=false
t3 旧响应覆盖乐观结果
cancelQueries 的目的不是让服务器撤销请求,而是阻止当前查询继续把旧结果提交到缓存。它解决的是客户端的响应竞态。
但它不能解决另一个竞态:
t0 mutation M1:设置 true
t1 mutation M2:设置 false
t2 M2 先到服务器并完成
t3 M1 后完成
最终状态取决于服务端的并发语义。客户端不能仅靠乐观更新保证“最后一次点击必然最后写入”。如果顺序重要,应在服务端使用版本号、条件更新、请求序列号或其他并发控制。
3. 列表乐观更新为什么更复杂
详情缓存只需要修改一个对象:
['todos', 'detail', id]
列表缓存还涉及:
- 该对象是否在当前页;
- 修改后是否仍符合过滤条件;
- 排序字段是否变化;
- 删除后后续页面是否需要补位;
- 多个分页查询是否同时存在;
- 无限列表的
pages和pageParams结构。
因此,列表乐观更新不是简单地“找到所有包含该对象的缓存然后修改”。很多场景更可靠的策略是:
- 乐观更新当前明确显示的缓存;
- mutation 成功或失败后失效相关列表;
- 让服务器重新计算过滤、排序和分页。
七、分页:页码是查询身份的一部分
1. 基础分页的缓存模型
传统页码分页通常把每一页作为独立查询:
['projects', { page: 1 }]
['projects', { page: 2 }]
['projects', { page: 3 }]
这意味着切换页码时:
- 当前页查询仍保留在缓存;
- 新页查询可能尚未有数据;
useQuery进入加载或后台获取状态;- 新页成功后写入自己的 key。
示例:
import {
keepPreviousData,
useQuery,
} from '@tanstack/react-query'
type Project = {
id: number
name: string
}
type ProjectPage = {
projects: Project[]
page: number
totalPages: number
}
async function fetchProjects(
page: number,
signal?: AbortSignal,
): Promise<ProjectPage> {
const response = await fetch(`/api/projects?page=${page}`, { signal })
if (!response.ok) {
throw new Error(`请求失败:${response.status}`)
}
return response.json() as Promise<ProjectPage>
}
function ProjectPagination() {
const [page, setPage] = React.useState(1)
const query = useQuery({
queryKey: ['projects', { page }],
queryFn: ({ signal }) => fetchProjects(page, signal),
// v5 中使用 keepPreviousData 函数,
// 切页时暂时保留上一页数据。
placeholderData: keepPreviousData,
})
if (query.isPending && !query.data) {
return <p>首次加载中……</p>
}
if (query.isError && !query.data) {
return <p>加载失败:{query.error.message}</p>
}
if (!query.data) return null
return (
<>
<ul style={{ opacity: query.isPlaceholderData ? 0.5 : 1 }}>
{query.data.projects.map((project) => (
<li key={project.id}>{project.name}</li>
))}
</ul>
{query.isFetching && <p>正在获取第 {page} 页……</p>}
<button
disabled={page === 1 || query.isFetching}
onClick={() => setPage((value) => value - 1)}
>
上一页
</button>
<span>
第 {page} / {query.data.totalPages} 页
</span>
<button
disabled={
query.isPlaceholderData ||
page >= query.data.totalPages ||
query.isFetching
}
onClick={() => setPage((value) => value + 1)}
>
下一页
</button>
</>
)
}
示例中的三个状态应区分:
isPending:当前查询还没有成功数据;isFetching:当前正在请求,可能已经有旧数据;isPlaceholderData:当前显示的是由 placeholder 策略提供的临时数据。
如果切页时用 isPending 覆盖整个列表,UI 会闪烁。保留上一页数据并用 isFetching 表示后台切换,通常更符合分页交互。
2. 页码分页的失败路径
当第 2 页请求失败时,正确的 UI 可能是:
第 1 页旧内容仍然存在或已经离开当前视图
第 2 页显示错误
提供重试
不要把“有上一页缓存”误当成“当前页请求成功”。查询结果和加载状态必须一起判断。
if (query.isError) {
return (
<div>
<p>第 {page} 页加载失败:{query.error.message}</p>
<button onClick={() => query.refetch()}>重试</button>
</div>
)
}
3. 无限滚动不是简单的页码分页
无限查询的缓存结构通常是:
{
pages: [
{ items: [...] },
{ items: [...] },
],
pageParams: [undefined, 'cursor-2']
}
它适合服务端游标分页:
import { useInfiniteQuery } from '@tanstack/react-query'
type FeedPage = {
items: Todo[]
nextCursor?: string
}
async function fetchFeed(
cursor: string | undefined,
signal?: AbortSignal,
): Promise<FeedPage> {
const params = new URLSearchParams()
if (cursor) params.set('cursor', cursor)
const response = await fetch(`/api/feed?${params}`, { signal })
if (!response.ok) {
throw new Error(`请求失败:${response.status}`)
}
return response.json() as Promise<FeedPage>
}
function Feed() {
const query = useInfiniteQuery({
queryKey: ['feed'],
queryFn: ({ pageParam, signal }) =>
fetchFeed(pageParam, signal),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.nextCursor,
})
const items = query.data?.pages.flatMap((page) => page.items) ?? []
return (
<>
{items.map((item) => (
<div key={item.id}>{item.title}</div>
))}
<button
disabled={
query.isFetchingNextPage || !query.hasNextPage
}
onClick={() => query.fetchNextPage()}
>
{query.hasNextPage ? '加载更多' : '没有更多内容'}
</button>
</>
)
}
游标分页的关键是:下一页参数来自上一页服务端响应,而不是客户端简单计算 page + 1。这能避免数据插入或删除导致页码偏移,但服务端必须正确维护游标语义。
八、并发 mutation 与回滚的真实边界
1. 多个 mutation 不能共用一个过时快照
假设初始值是:
completed = false
用户快速点击两次:
M1: 设置 true
M2: 设置 false
如果每个 mutation 都这样读取快照并在失败时回滚:
const previous = queryClient.getQueryData(...)
那么 M1 和 M2 可能都保存 false。如果 M1 失败,M1 的回滚会把 M2 已经写入的 false 之外的后续状态覆盖掉;在更复杂的增量更新中,回滚甚至会抹掉后一个 mutation 的成功结果。
可选处理方式包括:
- 禁止同一资源上的重复提交;
- 对同一实体按队列串行发送;
- 使用 mutationId 或版本号判断哪个结果仍然有效;
- 失败时不直接恢复整个快照,而是重新失效查询;
- 服务端提供条件更新,例如
If-Match或版本字段。
经验上,回滚整个列表快照适合简单、低并发的交互;对于可连续编辑、协作或高并发数据,应优先依赖服务端版本控制和最终重新同步。
2. 失效不能修复错误的 key
如果 mutation 后写的是:
queryClient.invalidateQueries({
queryKey: ['todos'],
})
但实际列表使用了:
['tasks', { page: 1 }]
那么不会匹配任何目标查询。UI 看起来“失效了但没刷新”,根因不是 TanStack Query 没有工作,而是缓存身份体系不一致。
可以集中定义 key 工厂:
const todoKeys = {
all: () => ['todos'] as const,
lists: () => [...todoKeys.all(), 'list'] as const,
list: (filters: {
userId: number
status: string
page: number
}) => [...todoKeys.lists(), filters] as const,
details: () => [...todoKeys.all(), 'detail'] as const,
detail: (id: number) => [...todoKeys.details(), id] as const,
}
使用时:
useQuery({
queryKey: todoKeys.list({ userId, status, page }),
queryFn: ({ signal }) =>
fetchTodos(userId, status, page, signal),
})
这不是 TanStack Query 的强制规范,但能减少字符串拼写和层级不一致造成的失效错误。
九、离线:请求失败、暂停和持久化是三个不同问题
“离线支持”不能只理解为 navigator.onLine === false。至少要区分:
- 是否有网络连接;
- 当前查询或 mutation 是否应该在无网络时暂停;
- 内存中的缓存是否能在页面重启后恢复;
- 恢复后如何重新获取或继续提交;
- 数据冲突如何解决。
1. 网络模式
TanStack Query 支持不同的 networkMode。常见模式是:
'online':没有网络时不主动执行网络请求;'always':无论在线状态如何都执行,适合不依赖网络的 queryFn;'offlineFirst':先尝试缓存或服务工作线程等本地路径;网络失败后按离线语义处理。
示例:
const query = useQuery({
queryKey: ['profile', userId],
queryFn: ({ signal }) => fetchProfile(userId, signal),
networkMode: 'offlineFirst',
})
这里的 offlineFirst 不会自动把远程数据变成本地数据库,也不会自动实现离线写入。它只是改变查询对网络状态和首次请求的处理方式。
查询函数仍然必须正确处理 HTTP 和网络错误:
async function fetchProfile(
userId: number,
signal?: AbortSignal,
): Promise<{ id: number; name: string }> {
const response = await fetch(`/api/users/${userId}`, { signal })
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
return response.json()
}
2. isPaused 与“有旧数据”不是一回事
离线时,一个查询可能:
- 已有旧数据,暂时继续显示;
- 尚未成功,但因为网络不可用而暂停;
- 有旧数据但刷新失败;
- mutation 已进入队列等待恢复。
UI 应显示不同信息:
if (query.isPending && query.isPaused) {
return <p>等待网络后开始加载……</p>
}
if (query.data) {
return (
<>
<p>最后同步:{new Date(query.dataUpdatedAt).toLocaleString()}</p>
{query.isPaused && <p>当前离线,显示本地缓存</p>}
{/* 渲染 query.data */}
</>
)
}
if (query.isError) {
return <p>加载失败:{query.error.message}</p>
}
具体状态字段应以当前 TanStack Query v5 类型为准。不要仅依赖 navigator.onLine 来判断服务是否可用:设备可能显示在线但 DNS、VPN、API 网关或认证服务不可用。
3. 缓存持久化
默认 Query Cache 只存在于当前 JavaScript 进程。页面刷新后,内存缓存会消失。要跨刷新保留,需要持久化插件和存储介质。
常见方案是使用:
@tanstack/react-query-persist-client@tanstack/query-async-storage-persister
示例:
import {
QueryClient,
QueryClientProvider,
} from '@tanstack/react-query'
import { PersistQueryClientProvider } from
'@tanstack/react-query-persist-client'
import { createAsyncStoragePersister } from
'@tanstack/query-async-storage-persister'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
gcTime: 24 * 60 * 60 * 1000,
},
},
})
const persister = createAsyncStoragePersister({
storage: window.localStorage,
})
export function AppProviders({
children,
}: {
children: React.ReactNode
}) {
return (
<PersistQueryClientProvider
client={queryClient}
persistOptions={{
persister,
maxAge: 24 * 60 * 60 * 1000,
}}
>
{children}
</PersistQueryClientProvider>
)
}
这个例子需要在浏览器客户端边界执行,因为它使用了 window.localStorage。在服务端渲染环境中,不能在服务端模块初始化阶段直接访问 window。
持久化的生命周期是:
启动应用
↓
从存储恢复 Query Cache
↓
渲染可恢复的数据
↓
根据 staleTime、网络状态和配置决定是否重新获取
↓
查询变化后再次写入持久化存储
持久化不等于永久正确:
- 缓存可能已经过期;
- 用户可能已经退出登录;
- 权限或租户可能已经改变;
- 存储中的数据可能是旧版本结构;
- localStorage 容量有限且是同步 API;
- 敏感数据写入浏览器存储会扩大泄露面。
登出时应清理与用户相关的缓存:
await queryClient.cancelQueries()
queryClient.clear()
// 同时清理持久化存储,具体方式取决于使用的 persister
如果多个用户共用同一个浏览器 profile,还应把用户身份、租户或权限范围纳入缓存隔离策略,不能只依赖组件卸载。
4. 离线 mutation 与重放风险
离线写入比离线读取复杂得多。一个 mutation 若要断网后继续提交,需要保存:
- mutation 的类型和参数;
- 提交顺序;
- 失败重试策略;
- 身份凭证是否仍然有效;
- 重放是否幂等;
- 与服务器当前数据冲突时如何处理。
例如“创建订单”不能因为重试而创建两次。服务端应支持幂等键:
POST /api/orders
Idempotency-Key: client-generated-uuid
服务端根据这个 key 保证同一逻辑操作重复提交不会产生多个订单。
TanStack Query 可以帮助管理 mutation 的状态和恢复机制,但它不会替应用决定业务冲突。以下情况都可能发生:
客户端离线修改标题为 A
另一台设备在线修改标题为 B
客户端恢复网络并提交 A
最终采用 A、B、合并还是拒绝,必须由服务端协议定义,例如:
- 最后写入胜出;
- 按版本号拒绝旧版本;
- 字段级合并;
- 返回冲突供用户处理。
十、服务端渲染、客户端边界和 hydration
Query Cache 是客户端内存对象,因此 React Server Components 或服务端渲染环境不能简单地把同一个全局 QueryClient 跨请求复用。
1. 服务端预取的基本数据流
典型流程是:
服务端请求
↓
创建本次请求专属 QueryClient
↓
prefetchQuery
↓
dehydrate
↓
把脱水状态传给客户端
↓
客户端 hydrate
↓
useQuery 读取同一 queryKey
↓
按 staleTime 等规则决定是否刷新
服务端预取示意:
import {
dehydrate,
HydrationBoundary,
QueryClient,
} from '@tanstack/react-query'
async function ServerPage() {
const queryClient = new QueryClient()
await queryClient.prefetchQuery({
queryKey: ['projects', { page: 1 }],
queryFn: () => fetchProjects(1),
})
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<ProjectPagination />
</HydrationBoundary>
)
}
客户端组件必须使用完全一致的 key:
const query = useQuery({
queryKey: ['projects', { page }],
queryFn: ({ signal }) => fetchProjects(page, signal),
})
如果服务端使用:
['projects', 1]
客户端使用:
['projects', { page: 1 }]
那么 hydration 无法命中同一缓存身份,客户端仍可能再次请求。
2. 服务端请求之间不能共享 QueryClient
错误模式:
// 模块级单例,服务端高风险
export const queryClient = new QueryClient()
服务端长期复用同一个 QueryClient,可能让不同用户或不同请求之间共享缓存,造成数据泄露。服务端应按请求创建 QueryClient;浏览器端通常可以在应用生命周期内复用一个 QueryClient。
3. hydration 不是实时同步
脱水状态只是某个时间点的缓存快照。客户端拿到它后仍可能遇到:
- 服务端数据已经变化;
- 用户权限变化;
- hydration 时间过长;
- 客户端和服务端时区、序列化格式不一致。
因此 hydration 解决的是首屏数据传输和重复请求问题,不是最终一致性保证。
十一、错误处理和请求取消必须进入数据流
1. HTTP 失败不会自动成为异常
fetch 对 HTTP 404 或 500 默认不会 reject。必须显式检查:
const response = await fetch(url, { signal })
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
return response.json()
否则 queryFn 可能把错误响应当成成功结果,Query 状态会错误地进入 success。
2. 查询参数变化时要处理响应竞态
推荐始终把 signal 传入支持取消的底层库:
queryFn: async ({ signal, queryKey }) => {
const [, filters] = queryKey as [
string,
{ search: string; page: number },
]
const response = await fetch(
`/api/search?q=${encodeURIComponent(filters.search)}&page=${filters.page}`,
{ signal },
)
if (!response.ok) throw new Error('搜索失败')
return response.json()
}
这里从 queryKey 读取参数可以减少闭包变量与 key 不一致的风险,但也要确保 key 的类型设计清晰。
3. 重试只适合可恢复的错误
查询默认可能进行重试,具体默认行为和版本配置应查当前文档。生产应用通常要区分:
- 网络暂时失败:可以重试;
- 429:应尊重服务端的限流信息;
- 401:重试本身通常无效,应刷新凭证或引导登录;
- 403:权限不足,不应无限重试;
- 404:资源确实不存在时通常不应反复请求。
例如:
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: (failureCount, error) => {
if (error instanceof Error && error.message.includes('401')) {
return false
}
return failureCount < 2
},
},
},
})
实际项目应使用结构化错误类型,而不是依赖错误消息文本:
class ApiError extends Error {
constructor(
message: string,
readonly status: number,
) {
super(message)
}
}
十二、诊断:从“界面没刷新”反推状态路径
遇到缓存或失效问题时,应按数据流排查,而不是先增加 refetch()。
情况一:mutation 成功,但列表没有变化
依次检查:
- mutation 的
onSuccess是否执行; - 失效的 key 是否与列表 key 的前缀一致;
- 是否错误使用了
exact: true; - 列表是否当前有 observer;
- 服务端 mutation 返回后,GET 接口是否真的能读到新值;
- 是否有旧的请求响应晚于 mutation 返回并覆盖缓存;
- 是否被
select、排序或过滤逻辑隐藏。
可以用 Devtools 查看:
- 当前实际 query key;
dataUpdatedAt;isStale;- 是否 active;
- 当前是否有 fetch;
- mutation 是否处于 pending 或 error。
情况二:页面频繁请求
检查:
staleTime
refetchOnMount
refetchOnWindowFocus
refetchOnReconnect
如果每次组件挂载都请求,可能只是 staleTime: 0 的预期行为,而不是缓存没有生效。
情况三:搜索输入导致请求过多
输入框每次变化都改变 key,理论上每次都可能触发查询:
['search', { q: inputValue }]
应把“输入值”和“提交查询值”分开,或使用 debounce。Debounce 的结果必须同时用于 key 和 queryFn:
const debouncedSearch = useDebouncedValue(search, 300)
useQuery({
queryKey: ['search', { q: debouncedSearch }],
queryFn: ({ signal }) =>
searchProducts(debouncedSearch, signal),
enabled: debouncedSearch.length > 0,
})
不能只 debounce queryFn,却让未 debounce 的值进入 key,否则缓存仍会为每个输入字符建立查询身份。
情况四:乐观更新后界面闪回
重点检查:
- 是否在
onMutate中取消了相关查询; - 是否有另一个列表缓存没有同步;
- mutation 是否并发;
- 服务端返回的字段是否覆盖了客户端临时字段;
onSettled重新获取时,服务端是否最终一致;- 是否把错误 mutation 的旧快照回滚到了另一个 mutation 的新状态。
十三、常见错误模式与反例
1. 把所有服务端数据放进一个大对象
queryKey: ['app-data']
然后在一个请求中返回用户、订单、通知和列表。这会导致:
- 任意一部分变更都可能使整个对象失效;
- 不同页面无法独立缓存;
- 分页和详情难以单独刷新;
- mutation 后难以精确更新。
更合理的是按资源和查询维度建模:
['user', userId]
['orders', { userId, page }]
['notifications', { userId, unreadOnly: true }]
2. 把 staleTime: Infinity 当作“永远正确”
staleTime: Infinity
只表示 TanStack Query 不会因为时间推移自动把它标记为 stale。它不代表服务端数据不会改变,也不阻止显式 invalidateQueries。
适合它的通常是版本化配置、不会在会话中变化的只读元数据;对于库存、权限、协作数据则需要明确同步策略。
3. 用 setQueryData 猜测复杂分页结果
删除第一页的最后一项,可能需要从第二页补一项;修改排序字段可能需要跨页移动;过滤条件变化可能导致对象完全离开列表。
如果客户端没有完整的分页规则和服务器返回信息,强行修改所有列表缓存会产生比重新获取更隐蔽的错误。此时:
await queryClient.invalidateQueries({
queryKey: ['projects', 'list'],
})
通常更容易证明正确。
4. 以为持久化缓存就是离线数据库
Query Cache 更接近“可失效的远程数据快照”,不是完整的事务数据库。它不自动提供:
- 多表事务;
- 查询索引;
- 冲突合并;
- 离线 mutation 队列的业务语义;
- 数据迁移;
- 加密存储。
如果应用需要大量离线编辑、复杂查询和冲突合并,应考虑专门的本地数据库和同步协议,再决定如何让 TanStack Query 作为读取层或同步层的一部分。
十四、一个最小但完整的客户端装配
import {
QueryClient,
QueryClientProvider,
} from '@tanstack/react-query'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
gcTime: 10 * 60_000,
retry: 2,
refetchOnWindowFocus: false,
},
},
})
export function ClientRoot() {
return (
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
)
}
前置条件:
npm install @tanstack/react-query
开发阶段可以安装 Devtools:
npm install -D @tanstack/react-query-devtools
并在客户端根组件中使用:
import { ReactQueryDevtools } from
'@tanstack/react-query-devtools'
function ClientRoot() {
return (
<QueryClientProvider client={queryClient}>
<App />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
)
}
一个应用通常在浏览器端复用一个 QueryClient,否则每次渲染都创建新实例会导致缓存丢失:
// 错误:每次组件渲染都可能创建新缓存
function Root() {
const client = new QueryClient()
return (
<QueryClientProvider client={client}>
<App />
</QueryClientProvider>
)
}
在模块级创建适用于纯客户端应用;在服务端渲染应用中则必须考虑请求隔离,不能把服务端请求共享成一个跨用户单例。
十五、核心判断框架
面对一份服务端数据,可以按以下顺序做出设计:
第一步:确定资源身份
问:
哪些变量会改变服务器返回结果?
把它们全部纳入 queryKey。
第二步:确定缓存粒度
问:
详情、列表、过滤结果和分页是否需要独立刷新?
根据更新范围设计 key 层级,而不是把所有结果放到一个 key。
第三步:选择 mutation 后策略
- 服务端返回完整可信对象:
setQueryData; - 结果会影响排序、分页或聚合:
invalidateQueries; - 用户必须立即看到变化且可以构造临时结果:乐观更新;
- 复杂并发或冲突场景:乐观结果只能是临时显示,最终仍需服务端校正。
第四步:区分加载状态
- 没有数据且正在首次请求:
isPending; - 已有数据但正在刷新:
isFetching; - 切页时显示上一页占位数据:
isPlaceholderData; - 网络不可用导致请求等待:
isPaused; - 请求失败:
isError。
第五步:决定离线保证的等级
仅显示旧数据
<
刷新时自动避开网络错误
<
页面刷新后恢复缓存
<
离线创建/修改并自动重放
<
离线冲突合并
每提高一级,都需要更多的存储、协议、幂等和冲突处理设计。TanStack Query 可以提供查询缓存和状态协调,但不会替应用定义服务端权威性。
TanStack Query 的关键不在于“把请求写成 hook”,而在于建立一套可证明的数据身份和状态转换关系:
缓存键错误会导致数据串线;失效范围错误会导致界面不一致;乐观更新没有回滚会导致失败后残留假数据;分页没有把页码或游标纳入身份会导致页面互相覆盖;离线没有幂等和冲突协议则可能重复写入或丢失修改。把这些因果关系分别建模,TanStack Query 才能成为可靠的数据状态层,而不只是一个请求缓存工具。
系列导航与关联阅读
- 系列入口:React 完整学习路线:从渲染与 Hooks 到服务端组件和生产架构
- 上一篇:React 登录与权限:路由、组件、Token 刷新、403 和状态恢复
- 下一篇:Redux Toolkit 深入:Slice、Thunk、Listener、RTK Query 和测试
官方资料
本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论