TanStack Query 在 React 中的完整新手指南(v5)

面向对象:第一次接触 TanStack Query 的同学(React 18+)
学习目标:掌握 useQueryuseMutation 的核心参数、状态和常见业务写法

1. 先搞懂它到底解决什么问题

在 React 里直接用 fetch + useEffect,你很快会遇到这些问题:

  • 同一个接口被重复请求
  • 切页面回来又要重新写加载状态
  • 缓存、重试、后台刷新、错误重试都要自己管

TanStack Query 把这些“服务端状态管理”统一处理了。

  • useQuery:读数据(GET)
  • useMutation:写数据(POST/PUT/PATCH/DELETE)

2. 官方默认行为(新手一定要先知道)

这部分来自官方 Important Defaults,很多“看起来奇怪”的行为其实是默认配置:

  • Query 默认会被认为是 stale(过期)。
  • 过期 Query 在这些时机可能自动重新请求:组件挂载、窗口重新聚焦、网络恢复。
  • 非活跃 Query(页面没在用)默认约 5 分钟后被垃圾回收(gcTime)。
  • Query 默认失败重试 3 次(指数退避)。
  • Mutation 默认失败不重试(retry: 0)。

3. 安装和初始化(只做一次)

pnpm add @tanstack/react-query
// src/main.tsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import App from './App'

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 30_000,
      gcTime: 5 * 60_000,
      retry: 1,
      refetchOnWindowFocus: false,
    },
  },
})

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <QueryClientProvider client={queryClient}>
      <App />
    </QueryClientProvider>
  </React.StrictMode>,
)

4. useQuery:核心参数 + 返回值

4.1 基本写法

const result = useQuery({
  queryKey: ['todos'],
  queryFn: ({ signal }) => fetchTodos({ signal }),
})

4.2 useQuery 主要参数(按优先级)

参数作用是否常用新手建议
queryKey缓存键(唯一标识)必会一律用数组:['todos', page, filters]
queryFn请求函数必会signal 传给请求库,支持自动取消
enabled是否允许自动执行高频依赖参数时用:enabled: !!id
staleTime多少时间内算“新鲜”高频列表一般 30s ~ 5min
gcTime非活跃缓存存活时间常用默认 5 分钟可先不改
retry失败重试次数常用后台不稳定可设 1~2
select转换返回数据常用给组件产出“已变形数据”
placeholderData占位数据策略常用分页时常用 keepPreviousData
refetchOnWindowFocus回到页面是否自动刷新常用中后台常设 false

4.3 useQuery 常见返回值

返回值含义典型用法
data接口数据渲染页面
error错误对象错误提示
statuspending / error / success统一状态机
fetchStatusfetching / paused / idle判断网络层状态
isPending首次加载首屏 loading
isFetching正在请求(含后台刷新)顶部“刷新中”提示
isLoadingisPending && isFetching懒查询场景常用
isPlaceholderData当前是占位数据分页按钮禁用判断
refetch手动重拉点击按钮刷新

5. useQuery 常用场景(带示例)

5.1 场景 A:页面打开自动拉列表

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

type Todo = { id: number; title: string; completed: boolean }

async function fetchTodos({ signal }: { signal?: AbortSignal }) {
  const res = await fetch('/api/todos', { signal })
  if (!res.ok) throw new Error('获取待办失败')
  return (await res.json()) as Todo[]
}

export function TodoList() {
  const { data, isPending, isError, error, isFetching } = useQuery({
    queryKey: ['todos'],
    queryFn: ({ signal }) => fetchTodos({ signal }),
    staleTime: 30_000,
  })

  if (isPending) return <p>加载中...</p>
  if (isError) return <p>错误:{(error as Error).message}</p>

  return (
    <div>
      <ul>
        {data?.map((todo) => (
          <li key={todo.id}>{todo.title}</li>
        ))}
      </ul>
      {isFetching ? <small>后台刷新中...</small> : null}
    </div>
  )
}

5.2 场景 B:依赖查询(Dependent Query)

先拿用户,再拿该用户的项目。官方建议用 enabled 控制第二个查询。

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

type User = { id: number; name: string }
type Project = { id: number; name: string }

const getUserByEmail = async (email: string): Promise<User> => {
  const res = await fetch(`/api/users?email=${encodeURIComponent(email)}`)
  if (!res.ok) throw new Error('查询用户失败')
  return res.json()
}

const getProjectsByUser = async (userId: number): Promise<Project[]> => {
  const res = await fetch(`/api/projects?userId=${userId}`)
  if (!res.ok) throw new Error('查询项目失败')
  return res.json()
}

export function ProjectListByEmail({ email }: { email: string }) {
  const userQuery = useQuery({
    queryKey: ['user', email],
    queryFn: () => getUserByEmail(email),
  })

  const userId = userQuery.data?.id

  const projectsQuery = useQuery({
    queryKey: ['projects', userId],
    queryFn: () => getProjectsByUser(userId as number),
    enabled: !!userId,
  })

  if (userQuery.isPending || projectsQuery.isPending) return <p>加载中...</p>
  if (userQuery.isError) return <p>用户加载失败</p>
  if (projectsQuery.isError) return <p>项目加载失败</p>

  return (
    <ul>
      {projectsQuery.data?.map((p) => (
        <li key={p.id}>{p.name}</li>
      ))}
    </ul>
  )
}

5.3 场景 C:分页查询避免 UI 闪烁(官方 keepPreviousData

import { keepPreviousData, useQuery } from '@tanstack/react-query'
import { useState } from 'react'

type ProjectsPage = {
  items: { id: number; name: string }[]
  hasMore: boolean
}

async function fetchProjects(page: number): Promise<ProjectsPage> {
  const res = await fetch(`/api/projects?page=${page}`)
  if (!res.ok) throw new Error('分页查询失败')
  return res.json()
}

export function ProjectPager() {
  const [page, setPage] = useState(1)

  const { data, isPending, isFetching, isPlaceholderData } = useQuery({
    queryKey: ['projects', page],
    queryFn: () => fetchProjects(page),
    placeholderData: keepPreviousData,
  })

  if (isPending) return <p>加载中...</p>

  return (
    <div>
      {data?.items.map((item) => (
        <p key={item.id}>{item.name}</p>
      ))}
      <button onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page === 1}>
        上一页
      </button>
      <button
        onClick={() => setPage((p) => p + 1)}
        disabled={isPlaceholderData || !data?.hasMore}
      >
        下一页
      </button>
      {isFetching ? <span> 加载中...</span> : null}
    </div>
  )
}

5.4 场景 D:懒查询(enabled: false)的正确理解

const query = useQuery({
  queryKey: ['users', keyword],
  queryFn: () => searchUsers(keyword),
  enabled: false,
})

// 手动触发
query.refetch()

官方要点(很重要):

  • enabled: false 时不会自动请求。
  • 会忽略 invalidateQueries/refetchQueries 的自动触发能力。
  • 这种写法偏命令式。若只是“有关键词再查”,更推荐:
enabled: keyword.trim().length > 0

6. useMutation:核心参数 + 返回值

6.1 基本写法

const mutation = useMutation({
  mutationFn: createTodo,
  onSuccess: () => queryClient.invalidateQueries({ queryKey: ['todos'] }),
})

6.2 useMutation 主要参数

参数作用是否常用新手建议
mutationFn执行写操作必会参数尽量用对象,后续扩展方便
onMutate发请求前(乐观更新)进阶高频先快更 UI,再失败回滚
onSuccess成功后必会刷新相关 Query 或直接写缓存
onError失败后必会提示错误、回滚
onSettled无论成功失败都会执行常用统一收尾逻辑
retry失败重试次数常用写操作默认不重试,按业务决定
mutationKeymutation 分组标识可选多 mutation 管理时使用

6.3 useMutation 常见返回值

返回值含义常见用途
mutate触发提交(回调)按钮点击
mutateAsync触发提交(Promise)await 串流程
statusidle / pending / error / success状态展示
isPending提交中按钮禁用、防抖
isError提交失败错误信息
isSuccess提交成功成功提示
error错误对象文案细化
variables最近一次提交参数回显
reset重置 mutation 状态关闭弹窗后清状态

7. useMutation 常用场景(带示例)

7.1 场景 A:新增成功后失效刷新(官方推荐)

import { useMutation, useQueryClient } from '@tanstack/react-query'

async function addTodo(payload: { title: string }) {
  const res = await fetch('/api/todos', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(payload),
  })
  if (!res.ok) throw new Error('新增失败')
  return res.json()
}

export function AddTodo() {
  const queryClient = useQueryClient()

  const mutation = useMutation({
    mutationFn: addTodo,
    onSuccess: async () => {
      await queryClient.invalidateQueries({ queryKey: ['todos'] })
    },
  })

  return (
    <button onClick={() => mutation.mutate({ title: '学习 TanStack Query' })} disabled={mutation.isPending}>
      {mutation.isPending ? '提交中...' : '新增'}
    </button>
  )
}

7.2 场景 B:用 mutation 返回值直接更新缓存(官方 setQueryData

import { useMutation, useQueryClient } from '@tanstack/react-query'

type Todo = { id: number; title: string; completed: boolean }

async function editTodo(input: { id: number; title: string }): Promise<Todo> {
  const res = await fetch(`/api/todos/${input.id}`, {
    method: 'PATCH',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ title: input.title }),
  })
  if (!res.ok) throw new Error('更新失败')
  return res.json()
}

export function EditTodoButton() {
  const queryClient = useQueryClient()

  const mutation = useMutation({
    mutationFn: editTodo,
    onSuccess: (updatedTodo, variables) => {
      queryClient.setQueryData(['todo', { id: variables.id }], updatedTodo)
    },
  })

  return <button onClick={() => mutation.mutate({ id: 1, title: '新标题' })}>更新标题</button>
}

注意:setQueryData 必须返回新对象,不要原地修改旧对象。

7.3 场景 C:乐观更新(官方常见模板)

import { useMutation, useQueryClient } from '@tanstack/react-query'

type Todo = { id: number; title: string; completed: boolean }

async function createTodo(payload: { title: string }): Promise<Todo> {
  const res = await fetch('/api/todos', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(payload),
  })
  if (!res.ok) throw new Error('新增失败')
  return res.json()
}

export function AddTodoWithOptimisticUI() {
  const queryClient = useQueryClient()

  const mutation = useMutation({
    mutationFn: createTodo,
    onMutate: async (newTodo, context) => {
      await context.client.cancelQueries({ queryKey: ['todos'] })
      const previousTodos = context.client.getQueryData<Todo[]>(['todos'])

      context.client.setQueryData<Todo[]>(['todos'], (old = []) => [
        ...old,
        { id: -1, title: newTodo.title, completed: false },
      ])

      return { previousTodos }
    },
    onError: (_error, _variables, onMutateResult, context) => {
      context.client.setQueryData(['todos'], onMutateResult?.previousTodos)
    },
    onSettled: async (_data, _error, _variables, _onMutateResult, context) => {
      await context.client.invalidateQueries({ queryKey: ['todos'] })
    },
  })

  return <button onClick={() => mutation.mutate({ title: '先乐观更新' })}>乐观新增</button>
}

8. 新手最容易踩的坑

  • 把写操作放进 useQuery,导致语义和状态都混乱。
  • queryKey 没带参数,分页/筛选共用同一缓存。
  • 看到 isFetching 就整页 loading,体验会闪烁。
  • enabled: false 用多了,失去 TanStack Query 的声明式优势。
  • 忘记把 signal 传给请求函数,取消请求能力失效。
  • setQueryData 直接改旧对象,导致缓存更新行为异常。

9. 实战记忆卡(新人版)

  • GET:useQuery
  • POST/PUT/PATCH/DELETE:useMutation
  • 提交成功后优先考虑:invalidateQueries
  • 首屏 loading:isPending
  • 后台刷新提示:isFetching
  • 依赖条件查询:enabled
  • 分页不闪屏:placeholderData: keepPreviousData

10. 官方文档(建议按顺序看)