React: TanStack Query(React Query)

最后更新:2026-08-26

Tom 上节课用自定义 Hook + axios 封装了 HTTP 请求,但新问题又出现了:仪表盘和侧边栏都显示用户数量,两个组件各自发一次请求(浪费带宽和服务器资源);用户在 A 页面修改了用户名,切换到 B 页面时数据还是旧的;编辑表单提交后需要手动触发数据重取。他意识到:需要一个"服务端状态管理"方案,把 API 数据当作一种特殊的状态来管理——有缓存、有过期时间、能自动同步


1. 你将学到



2. 概念图解

100%
flowchart TD
    A[useQuery 调用] --> B{缓存命中?}
    B -->|未命中| C[发起 API 请求]
    B -->|命中但过期| C
    B -->|命中且新鲜| D[直接返回缓存数据]
    C --> E[缓存数据]
    E --> F[渲染组件]
    F --> G{staleTime 是否到期?}
    G -->|未到期| H[数据标记为"新鲜"]
    G -->|已到期| I[数据标记为"过期"]
    I --> J{窗口切换回前台?}
    J -->|是| C
    I --> K{refetchInterval?}
    K -->|是| C
    I --> L{有新的 useMutation<br/>invalidate?}
    L -->|是| C

    style A fill:#e1f5fe,stroke:#0288d1
    style E fill:#fff3e0,stroke:#f57c00
    style H fill:#e8f5e9,stroke:#388e3c
    style I fill:#ffcdd2,stroke:#d32f2f

TanStack Query 的核心机制:优先从缓存读取 → 标记新鲜/过期 → 过期时自动触发重新请求。



3. 一个真实场景

Tom 的仪表盘需要显示用户总数、订单总数、最近订单列表。这些数据来自三个不同的 API,且需要保持实时性——用户在其他页面修改了数据,回到仪表盘时应该看到最新的结果。此外,"添加商品"表单提交后,商品列表应该自动刷新,而不是手动刷新页面。

(1) 问题:手动管理缓存太难

不使用 TanStack Query 时,Tom 不得不自己处理:

JSX
// 手动管理缓存 — 每个组件都要写类似的逻辑
function Dashboard() {
  const [data, setData] = useState(null)
  const [loading, setLoading] = useState(true)

  useEffect(() => {
    fetch('/api/stats').then(res => res.json())
      .then(d => { setData(d); setLoading(false) })
      .catch(e => { setLoading(false) })
  }, [])

  // 问题 1:切到其他页面再回来 → 重新请求(浪费!)
  // 问题 2:如果有另一个组件也需要同样数据 → 重复请求
  // 问题 3:数据不会自动刷新,用户看到的可能是几分钟前的数据
}
▶ 试一试

TanStack Query 用一个 useQuery Hook 解决了上述所有问题。

特性 手动 fetch + useEffect TanStack Query
缓存管理 无缓存,切走即丢失 自动缓存,按 queryKey 管理
去重请求 多组件用同一数据 → 重复请求 自动去重,只请求一次
自动刷新 窗口聚焦/重连自动重取
后台更新 staleTime 控制后台刷新
加载/错误状态 手动管理 loading/error 自动提供 data/loading/error
乐观更新 手动实现 onMutate + onError 回滚
BASH
npm install @tanstack/react-query

(2) 初始化 Provider

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

// 创建 QueryClient(通常在 app 入口处)
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 30 * 1000,      // 默认 30 秒内数据视为"新鲜"
      cacheTime: 5 * 60 * 1000,  // 缓存保留 5 分钟
      retry: 3,                  // 失败重试 3 次
      refetchOnWindowFocus: true, // 窗口切换回前台时重取
    }
  }
})

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <YourApp />
    </QueryClientProvider>
  )
}
▶ 试一试

4. useQuery:读取数据

▶ 示例 1:仪表盘数据获取

JSX 📖 仅展示
import { useQuery } from '@tanstack/react-query'

// 封装的 API 函数
async function fetchDashboardStats() {
  const response = await fetch('/api/dashboard/stats')
  if (!response.ok) throw new Error('获取统计数据失败')
  return response.json()
}

function Dashboard() {
  // useQuery 一行代码代替了 useState + useEffect + 手动缓存
  const {
    data: stats,        // 响应数据
    isLoading,          // 首次加载中(无缓存时)
    isFetching,         // 是否正在请求(包括后台重取)
    error,              // 错误对象
    refetch             // 手动触发重取
  } = useQuery({
    queryKey: ['dashboardStats'],   // 唯一标识,用于缓存匹配
    queryFn: fetchDashboardStats,   // 数据获取函数
    staleTime: 30 * 1000,           // 30 秒内不重新请求
    retry: 3,                       // 失败重试 3 次
  })

  if (isLoading) return <DashboardSkeleton />
  if (error) return <ErrorPanel message={error.message} onRetry={refetch} />

  return (
    <div className="dashboard">
      <StatCard title="用户总数" value={stats.users} />
      <StatCard title="订单总数" value={stats.orders} />
      <StatCard title="总收入" value={`$${stats.revenue}`} />
    </div>
  )
}

// 侧边栏也显示用户数量 — 使用相同的 queryKey,不会重复请求!
function Sidebar() {
  const { data: stats } = useQuery({
    queryKey: ['dashboardStats'],
    queryFn: fetchDashboardStats,
    staleTime: 30 * 1000,
  })

  return (
    <aside>
      <p>在线用户:{stats?.onlineUsers ?? '...'}</p>
    </aside>
  )
}
逻辑代码 41 行(超过 40 行限制,仅展示)

关键机制:相同的 queryKey 共享同一份缓存。 Dashboard 和 Sidebar 使用相同的 ['dashboardStats'],TanStack Query 会自动去重——两个组件只会触发一次 API 请求,数据返回后同时更新两个组件。

▶ 示例 2:带参数的查询

JSX
function ProductDetail({ productId }) {
  const { data, isLoading, error } = useQuery({
    queryKey: ['product', productId],    // queryKey 包含参数
    queryFn: async () => {
      const res = await fetch(`/api/products/${productId}`)
      if (!res.ok) throw new Error('商品不存在')
      return res.json()
    },
    enabled: !!productId,    // productId 为空时不发请求
    staleTime: 60 * 1000,
  })

  if (isLoading) return <p>加载商品详情...</p>
  if (error) return <p>错误:{error.message}</p>

  return (
    <div>
      <h2>{data.name}</h2>
      <p className="price">${data.price}</p>
      <p>{data.description}</p>
    </div>
  )
}
▶ 试一试

queryKey 中包含参数的意义: TanStack Query 使用 queryKey 做缓存的唯一标识。['product', 1]['product', 2] 是两条不同的缓存,互不干扰。当 productId 从 1 变为 2 时,优先从缓存读取 ['product', 2],如果有缓存且未过期就直接渲染,否则发请求。

▶ 示例:useQuery 返回的关键字段

字段 含义 使用场景
data 最后一个成功的响应数据 渲染 UI
isLoading 首次加载且无缓存数据 显示首次加载骨架屏
isFetching 任何正在进行的请求(包括后台重取) 显示后台刷新指示器
error 请求失败的错误对象 显示错误信息
refetch 手动触发重新请求的函数 "刷新"按钮
isStale 数据是否已过期 条件性显示更新提示


5. useMutation:写入数据

读取用 useQuery,写入用 useMutation。这是 TanStack Query 的黄金法则。

▶ 示例 3:添加商品并刷新列表

JSX 📖 仅展示
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'

// 商品列表查询
function useProducts() {
  return useQuery({
    queryKey: ['products'],
    queryFn: async () => {
      const res = await fetch('/api/products')
      return res.json()
    }
  })
}

function ProductManager() {
  const queryClient = useQueryClient()

  // 添加商品的 mutation
  const addProductMutation = useMutation({
    mutationFn: async (newProduct) => {
      const res = await fetch('/api/products', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(newProduct)
      })
      if (!res.ok) throw new Error('添加失败')
      return res.json()
    },
    // 成功后的操作:使商品列表缓存失效,触发重新请求
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['products'] })
      // 可选:同时显示成功提示
      alert('商品添加成功!')
    },
    // 失败处理
    onError: (error) => {
      alert(`添加失败:${error.message}`)
    }
  })

  // 数据
  const { data: products, isLoading } = useProducts()

  // 表单提交
  function handleSubmit(event) {
    event.preventDefault()
    const formData = new FormData(event.target)
    const newProduct = {
      name: formData.get('name'),
      price: Number(formData.get('price'))
    }
    addProductMutation.mutate(newProduct)
    event.target.reset()
  }

  return (
    <div>
      <h2>商品管理</h2>

      <form onSubmit={handleSubmit}>
        <input name="name" placeholder="商品名称" required />
        <input name="price" type="number" placeholder="价格" required />
        <button type="submit" disabled={addProductMutation.isLoading}>
          {addProductMutation.isLoading ? '提交中...' : '添加商品'}
        </button>
      </form>

      {addProductMutation.isError && (
        <p className="error">提交失败:{addProductMutation.error.message}</p>
      )}

      <hr />

      <h3>商品列表</h3>
      {isLoading && <p>加载中...</p>}
      {products && (
        <ul>
          {products.map(p => (
            <li key={p.id}>{p.name} — ${p.price}</li>
          ))}
        </ul>
      )}
    </div>
  )
}
逻辑代码 67 行(超过 40 行限制,仅展示)

核心流程: useMutation.mutate() 触发 POST 请求 → 服务器处理 → onSuccess 回调中执行 invalidateQueries → TanStack Query 自动重新请求 ['products'] 数据 → 列表 UI 自动更新。

为什么不是手动 setData? 调用 queryClient.setQueryData 可以手动更新缓存,但更好的做法是让 TanStack Query 通过 invalidateQueries 重新请求服务器数据——确保数据来源始终是服务器,避免前端缓存和服务端数据不一致。



6. 乐观更新

当网络延迟较高时,用户提交后需要等待服务器响应才能看到 UI 变化,体验不佳。乐观更新在请求发送前就立即更新 UI,如果请求失败再回滚到之前的状态。

▶ 示例 4:切换任务完成状态

JSX 📖 仅展示
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'

// 获取待办列表
function useTodos() {
  return useQuery({
    queryKey: ['todos'],
    queryFn: async () => {
      const res = await fetch('/api/todos')
      return res.json()
    }
  })
}

function TodoList() {
  const queryClient = useQueryClient()
  const { data: todos } = useTodos()

  // 切换完成状态 — 使用乐观更新
  const toggleMutation = useMutation({
    // 实际的 API 请求
    mutationFn: async ({ id, done }) => {
      const res = await fetch(`/api/todos/${id}`, {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ done })
      })
      if (!res.ok) throw new Error('更新失败')
      return res.json()
    },

    // ===== 乐观更新开始 =====
    onMutate: async ({ id, done }) => {
      // 1. 取消所有正在进行的 todos 查询(避免覆盖乐观更新)
      await queryClient.cancelQueries({ queryKey: ['todos'] })

      // 2. 保存当前缓存数据,用于失败时回滚
      const previousTodos = queryClient.getQueryData(['todos'])

      // 3. 立即更新缓存
      queryClient.setQueryData(['todos'], (old) =>
        old.map(todo =>
          todo.id === id ? { ...todo, done } : todo
        )
      )

      // 4. 返回旧数据用于回滚
      return { previousTodos }
    },
    // ===== 乐观更新结束 =====

    // 失败时回滚
    onError: (err, variables, context) => {
      if (context?.previousTodos) {
        queryClient.setQueryData(['todos'], context.previousTodos)
      }
    },

    // 无论成功还是失败,最终重新请求确保与服务端同步
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['todos'] })
    }
  })

  return (
    <ul>
      {todos?.map(todo => (
        <li key={todo.id} style={{ opacity: toggleMutation.isLoading ? 0.7 : 1 }}>
          <input
            type="checkbox"
            checked={todo.done}
            onChange={() => toggleMutation.mutate({ id: todo.id, done: !todo.done })}
          />
          <span style={{ textDecoration: todo.done ? 'line-through' : 'none' }}>
            {todo.title}
          </span>
        </li>
      ))}
    </ul>
  )
}
逻辑代码 59 行(超过 40 行限制,仅展示)

乐观更新三步曲:

  1. onMutate:请求发送前立即更新缓存,让用户马上看到效果;保存旧数据以便回滚
  2. onError:请求失败时用保存的旧数据恢复缓存(回滚)
  3. onSettled:无论成功失败,最终从服务器重新请求数据,确保绝对一致性


7. 服务端状态 vs 客户端状态

理解 TanStack Query 的理念需要区分两种状态:

维度 服务端状态(Server State) 客户端状态(Client State)
来源 后端 API / 数据库 前端本地(用户操作)
所有权 服务器拥有数据所有权 前端拥有所有权
持久性 存储在数据库 存储在内存或 localStorage
同步需求 需要与服务器保持同步 不需要与服务器同步
更新方式 通过 API 写入 + 重新获取 直接 setState
管理工具 TanStack Query Zustand / Redux Toolkit
示例 用户列表、商品数据、订单信息 弹窗开关、表单输入值、主题色

核心原则:

▶ 示例:TanStack Query DevTools

TanStack Query 提供了专用的 DevTools 组件,可以在开发阶段查看所有查询的缓存状态、过期时间、最近更新时间。

BASH
npm install @tanstack/react-query-devtools
JSX
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <YourApp />
      {/* 只在开发环境显示 DevTools */}
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  )
}
▶ 试一试

DevTools 的功能:

  1. 查看所有 queryKey 及其对应的缓存数据
  2. 查看每条缓存的 stale / active / inactive 状态
  3. 手动触发 refetch、invalidate、remove 操作
  4. 监控请求的网络耗时和响应大小

▶ 示例:QueryClient 的全局方法

除了 useQueryuseMutationqueryClient 对象还提供了多个全局方法,用于在组件外操作缓存:

JSX
import { queryClient } from './queryClient'

// 1. 使缓存失效(触发重新请求)
queryClient.invalidateQueries({ queryKey: ['products'] })

// 2. 清除所有缓存(用户登出时)
queryClient.clear()

// 3. 设置默认选项(全局修改 staleTime)
queryClient.setDefaultOptions({
  queries: { staleTime: 60 * 1000 }
})

// 4. 获取缓存数据(不触发请求)
const cachedData = queryClient.getQueryData(['products'])

// 5. 预取数据(用户 hover 到链接时提前加载)
queryClient.prefetchQuery({
  queryKey: ['product', '42'],
  queryFn: () => fetchProduct('42')
})
// 用户点击链接后,数据已经在缓存中,瞬间渲染

// 6. 取消正在进行的查询
queryClient.cancelQueries({ queryKey: ['product', '42'] })
▶ 试一试

预取数据(prefetchQuery) 是提升用户体验的有效手段。当用户鼠标悬停在某个链接上时,提前发起请求,用户点击后数据已经到位,实现"瞬开"效果。

▶ 示例:何时需要自定义 QueryClient 配置

初始化时的 defaultOptions 定义了全局默认值,但每个 useQuery 调用可以单独覆盖:

JSX
// 全局默认:30 秒新鲜期
const queryClient = new QueryClient({
  defaultOptions: {
    queries: { staleTime: 30 * 1000, retry: 3 }
  }
})

// 某个查询覆盖:数据几乎不变,新鲜期设为 10 分钟
function useProductCategories() {
  return useQuery({
    queryKey: ['categories'],
    queryFn: fetchCategories,
    staleTime: 10 * 60 * 1000,  // 10 分钟内不重取
  })
}

// 另一个查询覆盖:实时性要求高,禁用缓存
function useRealtimeNotifications() {
  return useQuery({
    queryKey: ['notifications'],
    queryFn: fetchNotifications,
    staleTime: 0,               // 数据立刻过期
    refetchInterval: 30 * 1000, // 每 30 秒自动轮询
  })
}
▶ 试一试

配置原则: 全局配置设保守值(较短的 staleTime),每个查询根据数据特性单独覆盖。高频变动的数据(通知、实时统计)设短的 staleTime 甚至开启轮询;低频变动的数据(分类列表、配置信息)设长的 staleTime 减少请求。


❓ 常见问题

Q TanStack Query 和 useEffect + fetch 比有什么核心优势?
A 三大核心优势:(1) 缓存共享——多组件用相同 queryKey 时自动去重,不重复请求;(2) 自动重取——窗口切换回前台、网络重连、定时轮询时自动刷新数据;(3) 生命周期管理——loading/error/data 自动管理,失败自动重试,取消请求不用手动写 AbortController。一个 useQuery 替代了 20 行 useEffect + fetch + useState。
Q staleTime 和 cacheTime 有什么区别?staleTime = 0 会怎样?
A staleTime 控制数据"新鲜度"——在该时间内数据被视为新鲜,不会触发自动重取。cacheTime 控制缓存保留时间——组件卸载后,缓存保留多久才被垃圾回收。staleTime = 0 意味着数据一旦返回就标记为过期,每次使用都会触发后台重取(但会先返回缓存数据再更新)。推荐设置 staleTime = 30s 避免请求抖动。
Q useMutation 的 onSuccess 中 invalidateQueries 和 setQueryData 怎么选?
A 优选 invalidateQueries——使缓存失效,让 TanStack Query 重新请求服务器数据,保证数据一致性。setQueryData 适合极少变更且已知返回格式的场景(如客户端唯一 ID 生成)。如果需要立即显示用户操作的结果(不等待服务器响应),用乐观更新(onMutate + 回滚)代替 setQueryData。
Q 多个组件使用相同的 queryKey,TanStack Query 如何决定何时触发新请求?
A 规则是:(1) 如果缓存命中且未过期(staleTime 内),直接返回缓存,不发请求;(2) 如果缓存命中但已过期,立即返回缓存 + 后台发起新请求;(3) 如果没有缓存,发起请求。不管有多少个组件订阅同一个 queryKey,只会发一次请求。
Q TanStack Query 和 Zustand 能不能一起用?
A 能,且推荐这样用。TanStack Query 管理服务端状态(API 数据),Zustand 管理客户端状态(UI 状态)。常见的架构:TanStack Query 获取和缓存数据 → 将数据注入 Zustand store 做二次加工 → 组件从 Zustand 读取最终状态。两者是互补关系,不是替代关系。

📖 小节


📝 作业

  1. 用 useQuery 创建一个用户列表组件:使用 https://jsonplaceholder.typicode.com/users 作为 API,实现缓存共享(两个组件使用相同 queryKey,验证只发一次请求),并添加手动刷新按钮。
  2. 用 useMutation 实现"添加商品"功能:表单提交后触发 POST 请求,成功后自动刷新商品列表,失败时显示错误提示。
  3. 实现一个"切换待办事项完成状态"的乐观更新:点击 checkbox 立即切换状态,如果 API 请求失败则回滚。写完后测试断开网络点击 checkbox,观察回滚效果。
Web-Tutorial.com

Web-Tutorial 技术团队

由多位开发者共同维护的编程教程平台。每篇教程由对应领域的开发者编写和审核,确保内容准确可靠。如发现任何问题,欢迎向我们反馈。

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏