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 的重复代码。

参考资料

相关推荐
搜狐技术产品小编20234 小时前
深度解析与业务实战:将 screenshot-to-code 改造为支持 React + Ant Design 的前端利器
前端·javascript·react.js·前端框架·ecmascript
Ruihong4 小时前
🔥Vue 转 React 实战:VuReact 实时监听开发指南
vue.js·后端·react.js
Hello--_--World5 小时前
React:useRef 超详细教程、forwardRef 详解、useImperativeHandle详解
前端·javascript·react.js
Hello--_--World7 小时前
React:解释什么是虚拟Dom?它的工作原理及其性能优化机制,深入理解 JSX、如何理解 UI = f(state)?
react.js·ui·性能优化
Hello--_--World7 小时前
React:useState 函数式更新、useContext 全解析、useReducer 深度解析
前端·react.js·前端框架
Hello--_--World8 小时前
React:useEffect 深度解析、useLayoutEffect 深度解析、useEffect 正确处理异步请求,和避免竞态条件?
前端·javascript·react.js
爱滑雪的码农8 小时前
React+three.js之项目搭建
前端·javascript·react.js
不会敲代码116 小时前
手写 Mini React:从 JSX 到虚拟 DOM 再到 render,搞懂 React 底层原理
前端·javascript·react.js
openKaka_18 小时前
createRoot 到底创建了什么:FiberRootNode 和 HostRootFiber 的初始化过程
前端·javascript·react.js