React 列表页别再手写 useEffect 请求了:从 0 认识 useQuery

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 ErroruseQuery 会把它放进 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]

只要 keywordstatuspagepageSize 任意一个变化,queryKey 就会变化。

queryKey 变化后,useQuery 会自动重新请求。

所以你不需要再写:

tsx 复制代码
useEffect(() => {
  fetchList();
}, [keyword, status, page, pageSize]);

queryKey 到底有什么用

queryKeyuseQuery 的核心。

你可以把它理解成接口缓存的名字。

比如:

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 不是随便写的。

它表示"合同列表"这份数据,而且这份数据由 keywordstatuspagepageSize 共同决定。

第二步,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 的重复代码。

参考资料

相关推荐
LaughingZhu13 小时前
智能体经典范式构建:ReAct、Plan-and-Solve 与 Reflection
前端·react.js·前端框架
飞天狗14 小时前
长列表滚动卡顿怎么破:虚拟列表选型 + react-virtuoso 实战调优指南
react.js·性能优化
binbin_5217 小时前
React 井字棋教程:用一个小游戏理解组件和状态
前端·react.js·前端框架
张元清18 小时前
React useObjectUrl Hook:预览文件与 Blob,不留内存泄漏(2026)
javascript·react.js
真的想不出名儿1 天前
Md编辑器整合-react
前端·react.js·编辑器
牛仔只喝牛奶2 天前
多端跨端技术选型:小程序 + H5 + App 的两栈拆分落地
react native·react.js
wear工程师2 天前
setState 到底是不是异步的?这题从 React 16 问到 18,标准答案早就变了
react.js·面试
wanghowie2 天前
LangGraph4j 落地(二)— ReAct 多步工具与 trace 扩展
前端·react.js·前端框架
csj502 天前
前端基础之《React(11)—state声明式》
前端·react.js
张元清2 天前
React useLocalStorage Hook:SSR 安全的持久化状态(2026)
javascript·react.js