React Query 指南

SWR 与 tanstack-query 的取舍

SWR 所有用法都可以被 tanstack-query cover。

但是 API 不是一致的,目前看来 tanstack-query 的 API 更原始,这意味着可以让结果更可控,同时更方便组合。

总体来讲,SWR 的舒适区非常小,适合应用规模很小没有复杂的 mutation 的场景,用熟练了会让人感觉四两拨千斤。而 tanstack query 的 API 几乎没有封装,暴露了非常多的 props 供人调整。

综述

Tanstack query 的 API 比 SWR 丰富很多,写大部分业务都是 SWR 的平替,一些 SWR 没有的复杂场景也基本都有 example 覆盖,开发体验好很多。

useSWRuseQuery

useSWRuseQuery 大部分参数名基本都相近,可以无缝替换。

有一个很关键的是 useQuery 没有 onSuccess onError 这种回调,这个在 tanstack-query 作者的博客里解释过原因。

mutatequeryClient

SWR 和 Tanstack query 在客户端维护的缓存数据的地方,后面称其为缓存仓库。

Tanstack query 与 SWR 的缓存仓库 (或者说异步状态的 store) 的设计天差地别,这也导致了两者对缓存仓库的 mutate 也有很大不同。

由于 SWR 的约定太多,对缓存仓库的随意操作会破坏行为,因此 SWR 选择不暴露缓存仓库,唯一对缓存仓库进行操作的方式就是那个蹩脚的 mutate。
mutate 两个用途,标记某处数据为脏数据等待更新,或是用一份新的数据替换原数据 (还有乐观更新的 rollback 相关机制),可以认为这覆盖了大部分简单场景。

但是 tanstack query 的 queryClient 可以直接通过 useQueryClient 拿到,可以很直观的执行大部分操作,更改大部分属性。

useMutationuseSWRMutation

useSWRMutationmutate 是一脉相承的,因此其局限性也被继承了过来,同时它还想 cover tanstack query 的一些场景,所以就变得更扭曲了,大概分为两部分讲述。

首先 useSWRMutation 顺便也做了 mutate 的工作,并且额外提供了一些 onSuccess 此类回调。

其次,当 useSWRMutation 没有传 key 的时候,就不会标记相关数据为脏,只提供状态管理功能。

useMutation 的设计就纯粹了很多,它只是一个用于数据变更的 useQuery,至于更新 store 里的数据,需要拿到 queryClient 再进行操作,不与 useMutation 耦合。

迁移到 tanstack-query 所需学习的

首先是一堆比较重要的默认值,至少 react-query 会把这些默认值给你讲了…

这个是最简单的用法,基本上已经 cover 了 70% 的用例了。这个是无限加载加上分页

对于 App router 场景,在 server component 里 fetch 到的数据,传给 useQueryfallbackData 就能有 SSR 了。

还有一份给 @tanstack/xx-query 用户的 swr 使用攻略,可以作为参考。

tanstack-query 使用文档

本文假设读者已经有了使用 swr 的经验,旨在引导读者快速迁移到 tanstack query。

是什么

  • 异步状态管理工具:他是状态管理器,不过管理的是异步操作 (主要是网络请求),这类状态的特点是异步、可重试、派生状态繁多,与同步状态截然不同。
  • 可组合的几组 hook:不像 swr 一样约定繁多,tanstack-query 因为其简洁,天生适合被组合,也因此可以融入很多场景。
  • 覆盖几乎全部场景的一站式请求管理方案:具体可以参考其文档,可以看到它内置了大部分场景的抽象,还有相关的 example。

不是什么

  • HTTP 请求方案:不像 swr 一样之前还偷偷塞一个默认 fetcher 实现,tanstack query 只关心 (opts) => Promise,不关心其他的任何事情。
  • 用于流式传输的状态管理工具:它不具有类似 SSE 这类流式传输的抽象,目前也没有风声说要做。

基础用法

useQuery

用于发起异步数据获取的 hook,常用场景是获取某些服务端的数据。

心智模型如下:

  1. 输入 query key (标识 query 唯一的 id,类型仅能是 Array<string | number>) 和用于请求的异步函数 (({ signal, ... }) => Promise<Data>)。
  2. 要对 query key 诚实一点,就像对 useEffect 的依赖数组一样,将异步函数依赖的所有状态都放在 query key 里。
  3. 请求的状态中,data 是返回的数据,error 是错误,都可能为 undefined
  4. 请求的状态中,在无数据且正在请求的时候 isLoadingtrue,可用于 loading screen 的逻辑判断。
  5. 在合适的时机,可以调用 hook 返回的 refetch 函数重新获取数据,比如某次用户提交了新数据的时候。

大部分时候可以这么用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const AppList: FC = () => {
// 我的偏好
// 不要在这里解构,因为一个组件可能不止有一个 useQuery,之后扩展会很麻烦
const query = useQuery({
queryKey: ["app", "list"],
queryFn: ({ signal }) => {
return await fetch("some endpoint", { signal });
},
});
if (query.isLoading) {
return <span>is loading</span>;
}
if (query.error) {
return <div>encounter error {error.message}</div>;
}
return (
<div>
{data!.map((item) => (
<div>{item.name}</div>
))}
</div>
);
};

useMutation

用于发起数据变更的 hook,常用场景是提交、更改某些数据,或是发起某个耗时的操作。
心智模型如下:

  1. 只用传入一个 mutateFn 就行,类型是 () => Promise<T>
  2. useQuery 的行为几乎一样,但是它不会自动调用、重试或是 refetch。
  3. 返回的 isMutating 状态可以用于标识当前是否在操作,当 isMutating 为 true 的时候应当禁用操作的按钮。
  4. 通过其返回的 mutate 或是 mutateAsync 来发起这个异步操作。
  5. 可以传入 onSuccessonErroronSettle 这类 event handler。
    大部分时候可以这么用:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const CreateApp: FC = () => {
const form = useForm()
const mutation = useMutation({
mutateFn: async () => {
if (form.isValid()) {
await fetch('some endpoint', {
method: 'POST',
data: JSON.stringify(form.getData())
})
} else {
throw new Error(form.errors())
}
}
onSuccess(data) {
alert(`${data.name} 创建成功`)
},
onError(err) {
console.error(err)
},
})
}

当然大部分场景这是不够的,变更完了之后还需要通知相关的 useQuery 的数据重新获取,直接拿到那个 useQuery 返回的 refetch 函数是最方便的,但是如果层级过远,不方便拿的话,还可以用 QueryClient 直接操作,具体实现后面会提到。

useQueryClient

用于获取 QueryClient,原理是 useContext<QueryClientContext>

主要用于在 mutation 中修改已经存在的 query 的数据,支持两种方式:

  1. 使用 invalidateQueries 函数,标记某些 query 数据为旧,tanstack query 会自动重新获取:
1
2
3
4
5
6
7
8
9
const queryClient = useQueryClient();

const mutation = useMutation({
mutationFn: () => fetch("some endpoint", { method: "POST" }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["todos"] });
queryClient.invalidateQueries({ queryKey: ["reminders"] });
},
});
  1. 前端可以得知新的内容,则可以直接用 setQueryData:
1
2
3
4
5
6
7
8
9
10
const mutation = useMutation({
mutationFn: () => fetch("some endpoint", { method: "POST" }),
onSuccess: (data, vars) => {
// 如果传 updater 的话,需要注意 oldData 是不可变的,它只会检查引用。
queryClient.setQueryData(["app", "list"], (oldData) => {
return [...oldData, data];
});
// 如果嫌麻烦的话,也可以直接拼一个值放在第二个参数传给 setQueryData
},
});

SSR 相关

useQuery 有两个参数可以为 SSR 的时候提供数据,适用的场景不同:

  1. placeholderData:设置一个默认过期的数据,在 SSR 的时候能拿到,但是在客户端会马上重新向服务端获取。
  2. initialData:设置一个不过期的数据,在 SSR 的时候能拿到。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const ServerComponent: FC = async () => {
const data = await fetch("some endpoint");
return <ClientCompoennt initData={data} />;
};

const ClientComponent: FC<{ initData: Data }> = async (props) => {
const { initData } = props;
const query = useQuery({
queryKey: ["client", "component"],
queryFn: () => fetch("some endpoint"),
initialData: initData,
// 或者
placeholderData: initData,
});
};

实战用例

互相依赖的 Query

如果一个 query 依赖另一个 query 的数据,则:

1
2
3
4
5
const queryA = useQuery({ queryKey: ["a"] });
const queryB = useQuery({
queryKey: ["b"],
enabled: !!queryA.data,
});

分页 & 过滤

一个复杂的列表页肯定要有分页和过滤条件的,它有最佳实践:

1
2
3
4
5
6
7
const [pageNumber, setPageNumbers] = useState(0);
const [category, setCategory] = useCategory("agent");
const query = useQuery({
// 注意此处 query key 传了页码和分类
queryKey: ["any", pageNumber, category],
queryFn: () => fetch(`${pageNumber} ${category}`),
});

由于 key 会变化,因此更新页码或是分类就能自动重新获取。

无限列表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
const InfinateList: FC = () => {
const infinateQuery = useInfiniteQuery({
queryKey: ["projects"],
queryFn: fetchProjects,
// 这是个范型,这里我们将 initialPageParam 设置为 number,之后的 getNextPageParam、getPreviousPageParam、queryKey 都会受到影响。
initialPageParam: 0,
getNextPageParam: (lastPage, pages, lastPageParam) => {
// lastPage 是这一页后端返回的数据,如果后端在分页列表里面加了列表的 cursor 或者 page number 之类的,直接从 lastPage 取就好
return lastPage.pageNumber + 1;
// 或者直接从上一次传入的 pageParams 派生,如果 pageParams 是数字,就直接 +1 就好
return lastPageParam + 1;
// pages 是之前所有页码的数据,我暂时想不到典型用例,蒙在鼓里
},
});

return (
<div>
{/* 注意此处的 data,会默认变成 { pages: Array<ReturnType<QueryFn>> } */}
{infinateQuery.data.pages.map((page) =>
page.items.map((item) => <Item item={item} />),
)}
{/* 直接调用 fetchNextPage 即可获取下一页的数据 */}
<button onClick={() => infinateQuery.fetchNextPage()}>下一页</button>
</div>
);
};

设计概要

Query

https://github.com/TanStack/query/blob/06e315c9ff413fb2d91b1f565bf5bfc9437c26ed/packages/query-core/src/query.ts#L156

作为一个状态机,在 tanstack query 中的每一次请求,无论是获取、mutate,其数据、状态与 action 都被抽象为 query,即成为 Query 这个 class 的实例。

除此之外,Query 还充当了观察者模式中的 publisher,变更后的 notify 也由 Query 负责。

这个 API 过于底层而且是私有的,因此在项目中不太好也不应该访问,至于为什么如此设计,之后会提到。

QueryCache

https://tanstack.com/query/latest/docs/reference/QueryCache

碎片化的 Query 虽然已经很好用了,但是还是统合起来才能更方便的做事 (如果库不提供的话就需要用户自己对缓存 store 建模了),QueryCache 是对这件事的第一层包装。

QueryCache 可以理解为 QueryMap,用途是根据 query key (索引) 找对应的 Query

问题同 Query 一样,还是过于底层了,因此还有更高一层抽象。

QueryClient

https://tanstack.com/query/latest/docs/reference/QueryClient

是对 QueryCache 的包装,提供了可以直接用于业务的操作,比如标记包含某 key 的数据为旧引起 refetch,获取含有某些 key 的 query 状态,prefetch 请求,修改请求缓存的数据等等。

useQuery

上面所提到的三层抽象都存在于 tanstack query 中框架无关的 core 中,这 core 中的数据变更,无论如何都无法驱动框架中的状态更新,因此针对每个框架 tanstack query 都提供了封装,在 react 中提供的封装是各种 hook,useQuery 是最常用的那个。

useQuery 的用途就是隔着层层抽象直接创建并订阅了某个 Query 的一切更新。

原理就是内部使用了 useSyncExternalStore 这个 hook 来实现观察者模式与 React 状态模型的同步,比 useEffect 实现干净非常多,不会在 strict mode run twice

Mutation、MutationCache 与 QueryClient

Mutation 是对于数据变更的建模,对应 QueryMutationCache 对应 QueryCache

虽然这两组 class 在核心逻辑中没有非常直接的继承关系 (MutationQuery 都继承了 publisher,但这并非核心),但是可以从代码看出这两者的相似:

https://github.com/TanStack/query/blob/06e315c9ff413fb2d91b1f565bf5bfc9437c26ed/packages/query-core/src/mutation.ts

https://github.com/TanStack/query/blob/06e315c9ff413fb2d91b1f565bf5bfc9437c26ed/packages/query-core/src/mutationCache.ts

因为这种相似性,所以 MutationMutationCache 还是被 QueryClient 管理的。

useMutation

useMutation 的用途就是隔着层层抽象直接创建并订阅了某个 Mutation 的一切更新。

设计模式

依附于 DI

这里的 DI (依赖注入) 泛指一切形式类似 context 和 zustand 的全局状态管理。

具体来说,就是将 useQueryuseMutation 互相组合好,然后通过 context 或者 zustand 分发并消费,目前 Dify 就是类似的架构,将 swr 的数据放在 context 往下传给子组件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
const AnyCtx = createContext();

const Provider: FC = () => {
const listQuery = useQuery({
queryKey: ["q"],
queryFn: () => fetch("some endpoint"),
});
const create = useMutation({
mutationFn: () => fetch("some endpoint"),
onSuccess: () => {
listQuery.refresh();
},
});
return (
<AnyCtx.Provider
value={{
list,
create,
}}
/>
);
};

const Comsumer: FC = () => {
const { list, create } = useContext(AppContext);
};

优势是:

  1. 比较中心化,显得比较像一个整体
  2. 想不出来了

劣势是:

  1. 不够灵活
  2. 容易将代码写成面条

Hook

由于 query 的数据是有 query key 标识的,因此非常契合 react hook 的抽象方式,可以写出这样的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const useAppList = () => {
return useQuery({
queryKey: ["q"],
queryFn: () => fetch("some endpoint"),
});
};
const useCreateApp = () => {
return useMutation({
mutationFn: () => fetch("some endpoint"),
onSuccess: () => {
listQuery.refresh();
},
});
};

// 可以在任何地方
const AnyWhere: FC = () => {
const create = useCreateApp();
const appList = useAppList();
return null;
};

优势是:

  1. 在状态的分发上会比较灵活
  2. 逻辑更原子化,比较清晰

劣势是:

  1. 需要每个 API 都写一个 hook,需要再找个地方集中放
  2. 可组合性更强也可能让一些逻辑耦合,但是过于灵活在哪都能用,又让结果难以预测

自行构建 QueryCache

一种适用于 Zustand 和 React Query 的前端数据管理方式

优势是:

  1. 最灵活,可以自己定制
  2. query store 相关的逻辑更好写

劣势是:

  1. 基本就把 tanstack query 的优势砍一大半,然后自己重写
  2. 代码组织更复杂,丢掉了 tanstack 的很多简化

React Query 指南
http://blog.akr.moe/react-query-guide/
作者
Akara Chen
发布于
2025年3月14日
许可协议