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. 查询是一个带身份的数据读取过程

一个查询可以抽象为:

Q=(K,F,S)Q = (K, F, S)

其中:

  • KKqueryKey,表示这份数据“是谁”;
  • FFqueryFn,表示如何读取数据;
  • SS 是查询状态,例如 pendingerrorsuccess、是否正在后台刷新等。

例如:

['todos', { userId: 42, page: 1 }]

可以表示“用户 42 的第 1 页待办事项”。

2. 缓存不是全局变量,而是按查询键索引的数据表

可以把 Query Cache 简化为:

Cache[hash(K)]={data, error, status, dataUpdatedAt, observers}\text{Cache}[\operatorname{hash}(K)] = \{ data,\ error,\ status,\ dataUpdatedAt,\ observers \}

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. 直接写入已知的服务器结果;
  2. 让相关查询失效并重新获取;
  3. 进行乐观更新,然后在后台校正。

二、缓存键:正确性首先取决于“身份建模”

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 时,两个请求可能共享同一条缓存。结果是:

  1. 第一个请求将全部任务写入 key;
  2. 第二个组件认为 key 相同,先读到旧数据;
  3. UI 出现过滤条件与数据不一致;
  4. 后台请求可能又把结果覆盖,表现为“偶尔恢复”。

这不是性能问题,而是缓存身份错误。

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
}) => {
  // ...
}

则至少应满足:

变量影响结果变量出现在 queryKey\text{变量影响结果} \Rightarrow \text{变量出现在 queryKey}

完整代码:

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. queryKeyqueryFn 必须保持一致

错误的抽象通常长这样:

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. staleTimegcTime 的关系

可以用时间线理解:

t0       请求成功,dataUpdatedAt = t0
         |<------ staleTime ------>|
         数据 fresh                 数据 stale
                                  仍可留在缓存中
组件卸载 ───────────────────────────────┐
                                      |<--- gcTime --->|
                                      缓存可被回收

因此:

  • stale:数据可能需要刷新;
  • inactive:当前没有组件观察它;
  • garbage collected:缓存记录已被回收。

一个数据可以同时是 stale 和 active,也可以是 fresh 和 inactive。


四、失效:不是“删除缓存”,而是改变新鲜度并触发后续同步

1. invalidateQueries 做了什么

调用:

await queryClient.invalidateQueries({
  queryKey: ['todos'],
})

通常会经历两个逻辑:

  1. 将匹配查询标记为 stale;
  2. 对当前正在被观察的匹配查询进行后台重新获取。

它不是简单的 removeQueries。已有数据通常仍可先用于渲染,然后等待新数据回来。

这使 UI 能够实现:

旧数据继续显示
      +
后台获取最新数据
      +
成功后原子替换缓存

2. invalidateQueriesrefetchQueriesremoveQueries 的区别

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
})

直接修改可能绕过引用变化判断,也会让调试和回滚更加困难。


六、乐观更新:先改变界面,再处理真实结果

乐观更新的前提是:

客户端能构造一个合理的临时结果,并且失败时能恢复原缓存。

完整过程是:

旧缓存保存上下文写入乐观结果{成功:校正或失效失败:回滚结束:重新同步\text{旧缓存} \rightarrow \text{保存上下文} \rightarrow \text{写入乐观结果} \rightarrow \begin{cases} \text{成功:校正或失效}\\ \text{失败:回滚}\\ \text{结束:重新同步} \end{cases}

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]

列表缓存还涉及:

  • 该对象是否在当前页;
  • 修改后是否仍符合过滤条件;
  • 排序字段是否变化;
  • 删除后后续页面是否需要补位;
  • 多个分页查询是否同时存在;
  • 无限列表的 pagespageParams 结构。

因此,列表乐观更新不是简单地“找到所有包含该对象的缓存然后修改”。很多场景更可靠的策略是:

  1. 乐观更新当前明确显示的缓存;
  2. mutation 成功或失败后失效相关列表;
  3. 让服务器重新计算过滤、排序和分页。

七、分页:页码是查询身份的一部分

1. 基础分页的缓存模型

传统页码分页通常把每一页作为独立查询:

['projects', { page: 1 }]
['projects', { page: 2 }]
['projects', { page: 3 }]

这意味着切换页码时:

  1. 当前页查询仍保留在缓存;
  2. 新页查询可能尚未有数据;
  3. useQuery 进入加载或后台获取状态;
  4. 新页成功后写入自己的 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。至少要区分:

  1. 是否有网络连接;
  2. 当前查询或 mutation 是否应该在无网络时暂停;
  3. 内存中的缓存是否能在页面重启后恢复;
  4. 恢复后如何重新获取或继续提交;
  5. 数据冲突如何解决。

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 成功,但列表没有变化

依次检查:

  1. mutation 的 onSuccess 是否执行;
  2. 失效的 key 是否与列表 key 的前缀一致;
  3. 是否错误使用了 exact: true
  4. 列表是否当前有 observer;
  5. 服务端 mutation 返回后,GET 接口是否真的能读到新值;
  6. 是否有旧的请求响应晚于 mutation 返回并覆盖缓存;
  7. 是否被 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,否则缓存仍会为每个输入字符建立查询身份。

情况四:乐观更新后界面闪回

重点检查:

  1. 是否在 onMutate 中取消了相关查询;
  2. 是否有另一个列表缓存没有同步;
  3. mutation 是否并发;
  4. 服务端返回的字段是否覆盖了客户端临时字段;
  5. onSettled 重新获取时,服务端是否最终一致;
  6. 是否把错误 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”,而在于建立一套可证明的数据身份和状态转换关系:

queryKey缓存定位新鲜度判断失效或后台获取mutation 后校正\text{queryKey} \rightarrow \text{缓存定位} \rightarrow \text{新鲜度判断} \rightarrow \text{失效或后台获取} \rightarrow \text{mutation 后校正}

缓存键错误会导致数据串线;失效范围错误会导致界面不一致;乐观更新没有回滚会导致失败后残留假数据;分页没有把页码或游标纳入身份会导致页面互相覆盖;离线没有幂等和冲突协议则可能重复写入或丢失修改。把这些因果关系分别建模,TanStack Query 才能成为可靠的数据状态层,而不只是一个请求缓存工具。


系列导航与关联阅读

官方资料

本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。