React 数据请求:TanStack Query
目标:用 Query 管理 loading / error / 缓存,而不是手写一堆
useEffect + useState。
引言
组件里的 useState 管 UI 状态;服务器上的列表 / 详情是另一类东西:要请求、等 loading、处理失败、缓存、再刷新。
手写 useEffect + fetch + 三个 useState 容易漏重试、漏竞态。TanStack Query(@tanstack/react-query)专门管这类「服务端状态」。
本篇 demo:本地 mock 待办 + 600ms 延迟,演示 loading / 列表 / 手动刷新(离线也能跑)。
动手
完整代码:react2/07-data-fetching/demo
bash
cd react2/07-data-fetching/demo
npm install && npm run dev
打开后先看到「加载中...」,再出列表;点「重新请求」会再走一遍 delay。
1. 为什么不用纯 useEffect
手写常见形态:
tsx
const [data, setData] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
fetch('/api/todos')
.then((r) => r.json())
.then(setData)
.catch(setError)
.finally(() => setLoading(false))
}, [])
缺什么:取消过期请求、窗口切回自动刷新、同 key 去重、失败重试、缓存共享......Query 把这些默认带上。
2. 先挂 QueryClientProvider
tsx
import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query'
const client = new QueryClient()
export default function App() {
return (
<QueryClientProvider client={client}>
<TodoList />
</QueryClientProvider>
)
}
useQuery 必须在 Provider 里面用。一般在应用根(App 或 main)包一层即可。
3. useQuery:声明「要这份数据」
tsx
async function fetchTodos(): Promise<Todo[]> {
await new Promise((r) => setTimeout(r, 600)) // 假装网络
return MOCK_TODOS
}
function TodoList() {
const { data, isPending, isError, error, refetch, isFetching } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
})
if (isPending) return <p>加载中...</p>
if (isError) return <p className="err">失败:{(error as Error).message}</p>
return (
<section>
<button type="button" onClick={() => refetch()} disabled={isFetching}>
{isFetching ? '刷新中...' : '重新请求'}
</button>
<ul>
{data.map((t) => (
<li key={t.id}>
<span className={t.completed ? 'done' : ''}>{t.title}</span>
</li>
))}
</ul>
</section>
)
}
| 字段 | 含义 |
|---|---|
queryKey |
这份数据的缓存身份证;相同 key 共享缓存 |
queryFn |
真正去拉数据的异步函数 |
isPending |
还没有成功数据(首屏 loading) |
isError / error |
失败 |
data |
成功后的结果(过了 pending/error 分支后可放心用) |
refetch |
手动再请求一次 |
isFetching |
正在请求中(含后台刷新;已有数据时也可能为 true) |
isPending vs isFetching:
首屏用 isPending 挡整页;点刷新时已有列表,用 isFetching 只禁用按钮 / 显示「刷新中」,别把整页清空。
4. queryKey 怎么设计
tsx
queryKey: ['todos'] // 整份列表
queryKey: ['todo', id] // 某一条(id 变就另缓存)
queryKey: ['todos', { q }] // 带筛选条件
规则直觉:
- key 变了 → 当作另一份查询
- 同一 key → 尽量复用缓存,少打重复请求
5. 接到真实 API
把 queryFn 换成 fetch 即可,其它 UI 分支不用动:
tsx
async function fetchTodos(): Promise<Todo[]> {
const res = await fetch('/api/todos')
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json()
}
建议:在 queryFn 里对非 2xx 抛错 ,这样会走 isError,而不是把错误 HTML 当成 data。
对照表
| 概念 | 干什么 | demo |
|---|---|---|
QueryClient |
缓存与调度中心 | 模块顶层 new QueryClient() |
QueryClientProvider |
注入 client | 包住 TodoList |
queryKey |
缓存键 | ['todos'] |
queryFn |
拉数 | fetchTodos(mock delay) |
isPending / isError |
首屏状态 | 加载中 / 失败文案 |
refetch + isFetching |
手动刷新 | 「重新请求」按钮 |
常见坑
| 做法 | 问题 |
|---|---|
| 忘了 Provider | useQuery 直接报错 |
queryKey 写成每次新对象却又不稳定 |
缓存命中乱、反复请求 |
只用 isFetching 做首屏 |
已有缓存时可能闪一下空态,语义和 isPending 不同 |
queryFn 失败却 return null 不 throw |
不会进 isError |
和 useState 里再存一份 data |
双数据源,以 Query 的 data 为准 |
demo 在练什么
- Provider +
useQuery拉 mock 列表 isPending/isError分支refetch手动刷新,用isFetching反馈按钮状态
自己改一改:让 fetchTodos 随机 throw new Error('网络炸了'),看错误分支;或把 key 改成 ['todos', query] 练筛选。
运行截图
