React 列表页别再手写 useEffect 请求了:从 0 认识 useQuery
很多 React 列表页,一开始都是这样写的:
tsx
const [list, setList] = useState([]);
const [loading, setLoading] = useState(false);
const [total, setTotal] = useState(0);
async function fetchList() {
setLoading(true);
const res = await getContractList({
keyword,
status,
page,
pageSize,
});
setList(res.list);
setTotal(res.total);
setLoading(false);
}
useEffect(() => {
fetchList();
}, [keyword, status, page, pageSize]);
这段代码没问题。
但列表页一复杂,就会出现这些问题:
text
loading 要自己维护
接口报错要自己维护
分页变化要自己请求
筛选变化要自己请求
新增、编辑、删除后要手动刷新
多个接口容易重复请求
组件卸载时还可能有竞态问题
所以很多项目会引入 TanStack Query,也就是以前大家常说的 React Query。
它解决的不是"怎么发请求",而是:怎么管理服务端状态。
什么是服务端状态
前端页面里的状态大概可以分两类。
第一类是前端自己的状态:
tsx
const [modalOpen, setModalOpen] = useState(false);
const [selectedRow, setSelectedRow] = useState(null);
这些状态只存在于当前页面交互里。
第二类是服务端状态:
tsx
const [list, setList] = useState([]);
const [total, setTotal] = useState(0);
这些数据本质上不属于前端。它们来自接口、数据库、后端服务。
服务端状态有几个特点:
text
需要异步请求
可能失败
需要缓存
需要重新拉取
可能被其他操作修改
需要和页面查询条件同步
useQuery 就是用来管理这类状态的。
useQuery 是什么
useQuery 是 @tanstack/react-query 提供的一个 React Hook。
最简单的用法长这样:
tsx
const query = useQuery({
queryKey: ['contracts'],
queryFn: getContractList,
});
它接收两个最核心的参数:
text
queryKey:这次查询的唯一标识
queryFn:真正发请求的函数
返回值里会有:
text
data:接口返回的数据
isPending:是否正在第一次加载
isFetching:是否正在请求
isError:是否请求失败
error:错误信息
refetch:手动重新请求
所以原来你自己维护的这些东西:
tsx
const [list, setList] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
很多时候都可以交给 useQuery。
安装
先安装:
bash
pnpm add @tanstack/react-query
或者:
bash
npm i @tanstack/react-query
本文按 TanStack Query v5 写。
第一步:在项目入口接入 QueryClientProvider
useQuery 不能直接裸用。
你需要先在应用外层放一个 QueryClientProvider。
比如 main.tsx:
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();
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</React.StrictMode>,
);
可以把 QueryClient 理解成一个查询管理器。
它负责管理:
text
接口缓存
请求状态
错误状态
重新请求
缓存失效
QueryClientProvider 的作用,就是把这个管理器提供给整个 React 应用。
如果你忘了包这一层,使用 useQuery 时通常会遇到类似错误:
text
No QueryClient set
第二步:写一个普通请求函数
假设我们有一个合同列表接口:
tsx
type Contract = {
id: string;
name: string;
status: 'draft' | 'active' | 'finished';
createTime: string;
};
type ContractListParams = {
keyword: string;
status: string;
page: number;
pageSize: number;
};
type ContractListResponse = {
list: Contract[];
total: number;
};
export async function getContractList(
params: ContractListParams,
): Promise<ContractListResponse> {
const searchParams = new URLSearchParams();
searchParams.set('keyword', params.keyword);
searchParams.set('status', params.status);
searchParams.set('page', String(params.page));
searchParams.set('pageSize', String(params.pageSize));
const res = await fetch(`/api/contracts?${searchParams.toString()}`);
if (!res.ok) {
throw new Error('获取合同列表失败');
}
return res.json();
}
这里注意一点:queryFn 要返回 Promise。
请求失败时,直接 throw Error。useQuery 会把它放进 error 状态里。
第三步:在组件里使用 useQuery
现在页面可以这样写:
tsx
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { getContractList } from './api';
function ContractPage() {
const [keyword, setKeyword] = useState('');
const [status, setStatus] = useState('');
const [page, setPage] = useState(1);
const [pageSize] = useState(20);
const query = useQuery({
queryKey: ['contracts', keyword, status, page, pageSize],
queryFn: () =>
getContractList({
keyword,
status,
page,
pageSize,
}),
});
if (query.isPending) {
return <div>加载中...</div>;
}
if (query.isError) {
return <div>{query.error.message}</div>;
}
return (
<div>
<input
value={keyword}
onChange={(event) => {
setKeyword(event.target.value);
setPage(1);
}}
placeholder="搜索合同名称"
/>
<select
value={status}
onChange={(event) => {
setStatus(event.target.value);
setPage(1);
}}
>
<option value="">全部</option>
<option value="draft">草稿</option>
<option value="active">进行中</option>
<option value="finished">已完成</option>
</select>
<ul>
{query.data.list.map((item) => (
<li key={item.id}>
{item.name} - {item.status}
</li>
))}
</ul>
<div>总数:{query.data.total}</div>
<button
disabled={page <= 1}
onClick={() => setPage(page - 1)}
>
上一页
</button>
<button onClick={() => setPage(page + 1)}>
下一页
</button>
</div>
);
}
重点看这里:
tsx
queryKey: ['contracts', keyword, status, page, pageSize]
只要 keyword、status、page、pageSize 任意一个变化,queryKey 就会变化。
queryKey 变化后,useQuery 会自动重新请求。
所以你不需要再写:
tsx
useEffect(() => {
fetchList();
}, [keyword, status, page, pageSize]);
queryKey 到底有什么用
queryKey 是 useQuery 的核心。
你可以把它理解成接口缓存的名字。
比如:
tsx
useQuery({
queryKey: ['contracts', '', '', 1, 20],
queryFn: ...
});
和:
tsx
useQuery({
queryKey: ['contracts', '测试', '', 1, 20],
queryFn: ...
});
这是两个不同的查询。
因为搜索关键词不同,所以缓存也应该不同。
更推荐的写法是把查询条件放进对象:
tsx
const query = useQuery({
queryKey: [
'contracts',
{
keyword,
status,
page,
pageSize,
},
],
queryFn: () =>
getContractList({
keyword,
status,
page,
pageSize,
}),
});
这样可读性更好。
queryKey 的设计原则是:
text
接口返回结果由哪些参数决定,这些参数就应该放进 queryKey
如果你漏了 status:
tsx
queryKey: ['contracts', keyword, page, pageSize]
但是请求里用了 status:
tsx
queryFn: () =>
getContractList({
keyword,
status,
page,
pageSize,
})
那就可能出现缓存不准的问题。
isPending 和 isFetching 有什么区别
很多人第一次用会分不清:
tsx
query.isPending
query.isFetching
可以简单理解:
text
isPending:当前没有数据,正在首次加载
isFetching:正在请求,不管有没有旧数据
比如第一次进页面:
text
没有旧数据
正在请求
isPending = true
isFetching = true
如果列表已经有数据了,你切换分页:
text
有旧数据
正在请求新数据
isPending = false
isFetching = true
所以页面上可以这样处理:
tsx
if (query.isPending) {
return <div>首次加载中...</div>;
}
return (
<div>
{query.isFetching && <div>正在刷新...</div>}
<ContractTable dataSource={query.data.list} />
</div>
);
这样体验会更好。
首次加载时显示大 loading。
已有数据后再次请求,只显示一个轻量刷新状态,不要把整个页面清空。
新增、编辑、删除后怎么刷新列表
列表页通常不只是查询,还会有新增、编辑、删除。
假设删除接口是:
tsx
async function deleteContract(id: string) {
const res = await fetch(`/api/contracts/${id}`, {
method: 'DELETE',
});
if (!res.ok) {
throw new Error('删除失败');
}
}
最简单的刷新方式是调用 refetch:
tsx
async function handleDelete(id: string) {
await deleteContract(id);
query.refetch();
}
这样能用,但有一个问题:refetch 只刷新当前这个 query。
如果页面上还有别的地方也用了合同列表相关数据,比如:
tsx
useQuery({
queryKey: ['contracts', { page: 1, pageSize: 20 }],
queryFn: ...
});
useQuery({
queryKey: ['contracts', { status: 'active', page: 1, pageSize: 20 }],
queryFn: ...
});
删除成功后,这些缓存其实也都过期了。
这时候更推荐的思路不是"拿到某一个 query 然后手动 refetch",而是告诉 QueryClient:
text
contracts 这一类查询已经不新鲜了
谁正在页面上用它,谁自己重新请求
这就是 useMutation 配合 invalidateQueries 的作用。
先看完整代码:
tsx
import {
useMutation,
useQuery,
useQueryClient,
} from '@tanstack/react-query';
function ContractPage() {
const queryClient = useQueryClient();
const contractQuery = useQuery({
queryKey: ['contracts', { keyword, status, page, pageSize }],
queryFn: () =>
getContractList({
keyword,
status,
page,
pageSize,
}),
});
const deleteMutation = useMutation({
mutationFn: deleteContract,
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: ['contracts'],
});
},
});
return (
<div>
{contractQuery.data?.list.map((item) => (
<div key={item.id}>
<span>{item.name}</span>
<button
disabled={deleteMutation.isPending}
onClick={() => deleteMutation.mutate(item.id)}
>
删除
</button>
</div>
))}
</div>
);
}
这段代码可以拆成三步看。
第一步,useQuery 负责查列表:
tsx
const contractQuery = useQuery({
queryKey: ['contracts', { keyword, status, page, pageSize }],
queryFn: () =>
getContractList({
keyword,
status,
page,
pageSize,
}),
});
这里的 queryKey 不是随便写的。
它表示"合同列表"这份数据,而且这份数据由 keyword、status、page、pageSize 共同决定。
第二步,useMutation 负责删数据:
tsx
const deleteMutation = useMutation({
mutationFn: deleteContract,
});
mutationFn 就是真正执行删除请求的函数。
当你调用:
tsx
deleteMutation.mutate(item.id);
item.id 会作为参数传给 deleteContract。
也就是说,下面两段是对应的:
tsx
deleteMutation.mutate(item.id);
tsx
async function deleteContract(id: string) {
// id 就是上面传进来的 item.id
}
第三步,删除成功后,让合同列表缓存失效:
tsx
const deleteMutation = useMutation({
mutationFn: deleteContract,
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: ['contracts'],
});
},
});
这里最关键的是:
tsx
queryClient.invalidateQueries({
queryKey: ['contracts'],
});
它不是直接调用某一个接口。
它做的是两件事:
text
1. 找到 queryKey 以 ['contracts'] 开头的查询
2. 把这些查询标记为已过期
比如下面这些查询都会被匹配到:
tsx
['contracts']
['contracts', { page: 1, pageSize: 20 }]
['contracts', { keyword: '测试', status: 'active', page: 1, pageSize: 20 }]
如果其中某个查询当前正在页面上被 useQuery 使用,它会在后台重新请求。
所以删除流程完整走下来是这样:
text
用户点击删除
↓
deleteMutation.mutate(item.id)
↓
调用 deleteContract(item.id)
↓
删除接口成功
↓
进入 onSuccess
↓
invalidateQueries({ queryKey: ['contracts'] })
↓
contracts 相关列表缓存过期
↓
当前页面正在展示的合同列表自动重新请求
↓
页面显示删除后的最新列表
这比到处传 refetch 更干净。
因为页面不需要关心"到底要刷新哪一个列表实例",只需要声明一件事:合同数据被改过了,相关查询应该重新确认最新数据。
staleTime 是什么
默认情况下,useQuery 会比较积极地认为数据"过期"。
如果用户切到别的页面再回来,或者窗口重新聚焦,它可能会重新请求。
这是合理的,因为服务端数据可能已经变了。
但有些数据没必要频繁刷新,比如字典、配置、选项列表。
可以加 staleTime:
tsx
const query = useQuery({
queryKey: ['status-options'],
queryFn: getStatusOptions,
staleTime: 5 * 60 * 1000,
});
这表示 5 分钟内,数据都算新鲜。
在这 5 分钟内,React Query 不会因为数据过期而主动重新请求。
列表页也可以根据业务加:
tsx
const query = useQuery({
queryKey: ['contracts', { keyword, status, page, pageSize }],
queryFn: () =>
getContractList({
keyword,
status,
page,
pageSize,
}),
staleTime: 30 * 1000,
});
如果列表数据实时性要求不高,设置一个短的 staleTime 可以减少不必要请求。
useQuery 不适合什么场景
useQuery 适合"读取数据"。
比如:
text
获取列表
获取详情
获取字典
获取用户信息
获取配置
它不适合直接做"修改数据"。
比如:
text
新增
编辑
删除
提交表单
上传文件
这些更适合用 useMutation。
简单区分:
text
GET 类查询:useQuery
POST/PUT/DELETE 类修改:useMutation
当然实际项目不一定完全按 HTTP 方法区分,但这个规则对大多数后台系统够用了。
最后整理一下
如果不用 useQuery,一个列表页通常要自己维护:
text
data
loading
error
fetchList
useEffect
refetch
缓存
重复请求
刷新时机
用了 useQuery 后,代码会更接近这样:
tsx
const query = useQuery({
queryKey: ['contracts', { keyword, status, page, pageSize }],
queryFn: () =>
getContractList({
keyword,
status,
page,
pageSize,
}),
});
你只要关心三件事:
text
queryKey 怎么描述这份数据
queryFn 怎么请求这份数据
页面怎么消费 data / loading / error
这就是 useQuery 的价值。
它不是替你封装 fetch,而是帮你管理服务端状态。
对于后台管理系统、列表页、详情页、字典数据、用户信息这类场景,它能明显减少手写 useEffect + loading + setData 的重复代码。