React 数据请求:TanStack Query
前置:React 路由:React Router 目标:用 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
cd react2/07-data-fetching/demo
npm install && npm run dev
打开后先看到「加载中…」,再出列表;点「重新请求」会再走一遍 delay。
1. 为什么不用纯 useEffect
手写常见形态:
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
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:声明「要这份数据」
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 怎么设计
queryKey: ['todos'] // 整份列表
queryKey: ['todo', id] // 某一条(id 变就另缓存)
queryKey: ['todos', { q }] // 带筛选条件
规则直觉:
- key 变了 → 当作另一份查询
- 同一 key → 尽量复用缓存,少打重复请求
5. 接到真实 API
把 queryFn 换成 fetch 即可,其它 UI 分支不用动:
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。
对照表
| 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 在练什么
自己改一改:让 fetchTodos 随机 throw new Error('网络炸了'),看错误分支;或把 key 改成 ['todos', query] 练筛选。
运行截图

下一篇
React 大型项目目录骨架




