TanStack Query 基础

前置:React 组件与 Hooks、Promise;已完成环境搭建。适用 TanStack Query v5。目标是亲手验证读取、错误、写入、失效四个行为。

要点

Query 保存远端结果快照;queryKey 标识“哪一个问题的答案”,queryFn 负责获取。写操作成功后,需要明确哪些答案已经过期。

先分清两种状态

输入框正在编辑的标题由当前组件决定,放在 useState。服务器保存的待办可能被其他客户端修改,本地只能缓存一份快照,由 Query 管理。Query 不替你存数据库,也不会自动知道写入影响哪条列表。

本课使用内存模拟 API:保留 Promise 边界和真实写入效果,去掉网络安装变量。它不是实际 HTTP 服务,刷新页面会重置数据。这样可以先证明缓存流程,再接自己的后端。公共 JSONPlaceholder 会模拟写入响应而不持久保存,不能用它证明“删除后重新查询记录消失”。参见其使用说明。

一个文件跑通

在上一课的 React 工程中替换 src/App.tsx,依赖为 react 与 @tanstack/react-query。本例自己提供 QueryClientProvider;入口只渲染 App 即可。按工程 package.json 中的开发脚本启动。以下是完整组件文件,没有隐藏的 api、TodoList 或数据库定义。

import { useState } from 'react'
import {
  QueryClient,
  QueryClientProvider,
  useMutation,
  useQuery,
  useQueryClient,
} from '@tanstack/react-query'

type Todo = { id: number; title: string }
let rows: Todo[] = [{ id: 1, title: '理解缓存的身份' }]
let nextId = 2
let failNextRead = false
const delay = () => new Promise<void>((resolve) => setTimeout(resolve, 300))

async function readTodos(): Promise<Todo[]> {
  await delay()
  if (failNextRead) {
    failNextRead = false
    throw new Error('模拟读取失败,请重试')
  }
  return rows.map((row) => ({ ...row }))
}

async function createTodo(title: string): Promise<Todo> {
  await delay()
  const normalized = title.trim()
  if (!normalized) throw new Error('标题不能为空')
  const todo = { id: nextId++, title: normalized }
  rows = [...rows, todo]
  return { ...todo }
}

// 模块级实例在这份纯客户端练习中保持稳定;SSR 需要按请求隔离。
const client = new QueryClient()

function TodoPage() {
  const [title, setTitle] = useState('')
  const queryClient = useQueryClient()
  const query = useQuery({
    queryKey: ['todos'],
    queryFn: readTodos,
    staleTime: 10_000,
    retry: false, // 练习时立即观察错误;生产环境另定重试策略
  })
  const mutation = useMutation({
    mutationFn: createTodo,
    onSuccess: async () => {
      await queryClient.invalidateQueries({ queryKey: ['todos'] })
      setTitle('')
    },
  })

  if (query.isPending) return <p>首次加载中...</p>
  // 无数据的失败也必须进入错误分支,不能被 data === undefined 吞掉。
  if (query.isError && query.data === undefined) {
    return <div role="alert">
      <p>{query.error.message}</p>
      <button onClick={() => void query.refetch()}>重试</button>
    </div>
  }

  return <main>
    <h1>待办</h1>
    {query.isFetching && <p>正在同步...</p>}
    {query.isError && <p role="alert">{query.error.message};保留上次结果</p>}
    {query.data?.length === 0 && <p>还没有待办</p>}
    <ul>{query.data?.map((todo) => <li key={todo.id}>{todo.title}</li>)}</ul>
    <form onSubmit={(event) => {
      event.preventDefault()
      mutation.mutate(title)
    }}>
      <label>标题 <input value={title} onChange={(e) => setTitle(e.target.value)} /></label>
      <button disabled={mutation.isPending}>
        {mutation.isPending ? '提交并同步中...' : '添加'}
      </button>
    </form>
    {mutation.isError && <p role="alert">{mutation.error.message}</p>}
    <button disabled={query.isFetching} onClick={() => {
      failNextRead = true
      void query.refetch()
    }}>模拟下一次读取失败</button>
    <button disabled={query.isFetching} onClick={() => void query.refetch()}>重新读取</button>
  </main>
}

export default function App() {
  return <QueryClientProvider client={client}><TodoPage /></QueryClientProvider>
}

沿着一次操作理解代码

  1. 组件订阅 ['todos'],没有数据时进入 pending,queryFn 返回 Promise。
  2. 读取成功后,结果进入这条键对应的缓存,界面显示初始记录。
  3. 输入由 useState 保存;点击添加才调用 mutation,读取不会替你触发写入。
  4. 写入成功改变模拟服务的 rows,但读取缓存不会凭空知道它已改变。
  5. onSuccess 使列表失效;活跃查询重新获取数据。返回并等待这个 Promise,让 mutation 的等待阶段覆盖同步过程。

注意:写成功与随后刷新成功是两个结果。如果真实后端已经创建成功而重取失败,应允许重试读取;直接再次提交可能创建重复记录。

缓存的两个维度

问题对应概念本例中的含义
这份答案属于谁?queryKey['todos'];按用户过滤时必须把用户 ID 也放进键
是否需要重新确认?staleTime / invalidate十秒内视为新鲜;显式失效可以提前要求重新确认
是否还有人使用?活跃与非活跃查询当前页面使用列表,所以失效后通常会重新读取
没人使用后留多久?gcTime管非活跃缓存回收,与“新鲜多久”不是同一计时器

不是 staleTime 一到就立即发请求;通常还需要挂载、重新聚焦等触发条件。也不是先 stale 才能回收:回收关注是否无人使用。详见缓存参考。

类型与错误为什么这样判断

现代 TypeScript 可以保留 const 解构后的判别联合关系。const { data, isSuccess } = useQuery(...) 后,在 isSuccess 分支中 data 可以收窄;“解构就一定丢失收窄”是不正确的。只排除 pending 仍可能剩下 error,才是 data 可能为空的原因。TanStack 类型说明和TypeScript 4.6 说明给出了这一机制。

本例保留查询对象以便阅读。首次失败且无数据显示错误;后台失败且有历史数据则显示错误并保留结果。错误不能被统一的“没数据就加载”分支遮住。

换成 HTTP 时补上什么

以下是替换 readTodos 的局部片段。前提是你的后端确实实现了同源 /api/todos,返回符合 Todo 数组的 JSON;实际外部输入还应做运行时结构校验。

async function readTodos(): Promise<Todo[]> {
  const response = await fetch('/api/todos')
  if (!response.ok) throw new Error(`读取失败:HTTP ${response.status}`)
  return response.json()
}

创建请求还要指定方法、Content-Type、请求体,检查响应状态。fetch 收到 400/500 通常仍成功得到 Response,不检查 ok 就可能把错误响应当成业务成功。

练习与验收

操作预期结果它验证什么
首次打开加载后显示一条记录读取与成功分支
输入空格并提交标题不能为空,列表不增加mutation 错误与业务校验
添加“写一个测试”等待后列表增加,输入清空写入、失效和重取
点击模拟读取失败显示错误且保留旧列表后台错误不覆盖历史结果
点击重新读取恢复正常可恢复错误
刷新浏览器恢复初始数据内存模拟服务的生命周期

扩展:实现删除 mutation。提示是先按 ID 修改模拟 rows,再失效同一键;验收为重取后该记录消失。完成后阅读 Table 基础或查 Query API。

模式不变量

  • 远端事实、查询快照和输入草稿的拥有者不同。
  • 缓存身份必须涵盖影响结果的输入。
  • 写入成功后需要确定受影响的读取范围。
  • 首次失败与有历史数据的后台失败需要分别设计界面。

可复跑:queryKey 隔离缓存身份

queryKey 不是展示标签,而是缓存身份的一部分。把用户 ID 放进请求却漏出键,会让不同用户读取同一个缓存槽位。下面的最小程序直接使用 Query v5 的 QueryClient,验证两个键的缓存彼此独立,并验证失效不会清除已有快照。

import { QueryClient } from '@tanstack/react-query'

const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const aliceKey = ['todos', 'alice']
const bobKey = ['todos', 'bob']

await client.fetchQuery({ queryKey: aliceKey, queryFn: async () => ['alice task'] })
await client.fetchQuery({ queryKey: bobKey, queryFn: async () => ['bob task'] })
await client.invalidateQueries({ queryKey: aliceKey, exact: true })

if (client.getQueryData(aliceKey)?.[0] !== 'alice task') throw new Error('Alice 快照丢失')
if (client.getQueryData(bobKey)?.[0] !== 'bob task') throw new Error('不同用户缓存串联')
if (!client.getQueryState(aliceKey)?.isInvalidated) throw new Error('目标键未失效')
if (client.getQueryState(bobKey)?.isInvalidated) throw new Error('非目标键被错误失效')
client.clear()
console.log('TanStack query key contracts passed')

这验证的是 Query 核心的键与失效语义,不挂载 React 组件、不覆盖网络重试、Provider 生命周期或服务端 hydration;这些行为仍要在应用测试中验证。

暂无评论

发送评论 编辑评论

|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇