这篇文章讲的是 TanStack Query、TanStack Store、TanStack Router 三个库的实际使用思路,结合真实业务场景说明"为什么这么用",以及跟其他同类方案比有什么不同。
先说说这三个包是干什么的
json
"@tanstack/react-query": "^5.x",
"@tanstack/react-store": "^0.x",
"@tanstack/react-router": "^1.x"
分工非常清晰:
- react-query:管"服务器状态",也就是从接口拿来的数据
- react-store:管"客户端状态",纯前端的 UI 状态
- react-router:管路由,基于文件系统自动生成
一、TanStack Query:接口数据管理
从"手写请求"说起
大多数人第一次写数据请求,差不多是这样:
tsx
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => {
setUser(data);
setLoading(false);
})
.catch(err => {
setError(err);
setLoading(false);
});
}, [userId]);
if (loading) return <Spin />;
if (error) return <div>出错了</div>;
return <div>{user?.name}</div>;
}
写一次还好,写多了就会发现几个问题一直在重复出现:
- 同一个接口在两个组件里各请求了一次,明明数据一样,却发了两次请求
- 切换路由回来,数据已经过期,但没有任何机制去刷新
- loading / error / data 这套模板,每个接口都得写一遍
- 组件卸载后 setState 报错,要手动加 cleanup
这些其实都是"服务器状态管理"的问题,不是 React 本身的问题。
换成 TanStack Query
tsx
function UserProfile({ userId }) {
const { data: user, isLoading, error } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
if (isLoading) return <Spin />;
if (error) return <div>出错了</div>;
return <div>{user?.name}</div>;
}
代码少了,但做的事情更多了:
- 相同
queryKey的请求全局只发一次,多个组件共享同一份缓存 - 窗口重新聚焦时自动后台刷新(可配置)
- 组件卸载时自动取消,不会有 setState 警告
- 内置 loading / error / data 状态,不用手动维护
和 SWR 比有什么不同
SWR 是 Vercel 出的,功能和 TanStack Query 有很多重叠。主要区别:
| 对比项 | TanStack Query | SWR |
|---|---|---|
| Mutation 支持 | useMutation,完善 |
需要手动实现或用第三方 |
| 开发者工具 | 官方 Devtools,可视化缓存状态 | 无官方 Devtools |
| 缓存控制粒度 | 极细,可以按 key 精准失效 | 相对粗粒度 |
| 重试策略 | 自定义函数,按错误类型决定 | 配置项较少 |
| 包体积 | 较大 | 更小 |
| 使用场景 | 复杂应用,需要精细控制 | 简单场景,快速上手 |
如果项目比较简单、主要是 GET 请求展示数据,SWR 完全够用。但我们的项目涉及大量 mutation、复杂的缓存失效逻辑、自定义错误处理,TanStack Query 的控制粒度更合适。
Query Key:数据的"身份证"
queryKey 是 TanStack Query 的核心概念。它相当于每份缓存数据的身份证,同一个 key 对应同一份数据。
ts
// 静态 key:数据和参数无关
useQuery({ queryKey: ['currentUser'], queryFn: fetchCurrentUser });
// 动态 key:参数不同,缓存独立存储
useQuery({ queryKey: ['user', userId], queryFn: () => fetchUser(userId) });
useQuery({ queryKey: ['posts', { page, status }], queryFn: () => fetchPosts(page, status) });
key 设计得好,缓存就清晰。比如删除一篇文章后,让 ['posts'] 相关的缓存失效,列表会自动刷新:
ts
const deleteMutation = useMutation({
mutationFn: deletePost,
onSuccess: () => {
// 精准失效:以 ['posts'] 开头的所有缓存都失效
queryClient.invalidateQueries({ queryKey: ['posts'] });
}
});
用 query-key-factory 统一管理 key
项目大了之后,key 散落在各处很难维护。我们用 @lukemorales/query-key-factory 把所有 key 集中管理:
ts
import { createQueryKeys, mergeQueryKeys } from '@lukemorales/query-key-factory';
const userKeys = createQueryKeys('users', {
// 无参数的静态 key:对象形式
current: {
queryKey: null,
queryFn: fetchCurrentUser,
},
// 有参数的动态 key:函数形式
detail: (id: string) => ({
queryKey: [id],
queryFn: () => fetchUser(id),
}),
list: (params: UserListParams) => ({
queryKey: [params],
queryFn: () => fetchUsers(params),
}),
});
const postKeys = createQueryKeys('posts', {
list: (params: PostListParams) => ({
queryKey: [params],
queryFn: () => fetchPosts(params),
}),
});
// 合并成统一入口
export const queries = mergeQueryKeys(userKeys, postKeys);
在组件里:
tsx
// 静态 key,直接传(不加括号)
const { data: currentUser } = useQuery(queries.users.current);
// 动态 key,传参调用(加括号)
const { data: user } = useQuery(queries.users.detail(userId));
const { data: posts } = useQuery(queries.posts.list({ page, status }));
静态和动态 key 的写法差异很关键,一定要区分:
- 无参数 → 写成对象,用时不加括号
- 有参数 → 写成函数,用时加括号传参
在 React 之外读缓存
有时候需要在非 React 环境(工具函数、事件回调等)里读接口数据,不能用 hook。把 QueryClient 做成单例,在任何地方都能访问:
ts
// query-client.ts(单例)
export const queryClient = new QueryClient({ ... });
// 工具函数里
import { queryClient } from './query-client';
export function getCurrentUserId() {
// 直接从缓存读,同步返回,不发请求
const user = queryClient.getQueryData(queries.users.current.queryKey);
return user?.id;
}
getQueryData 和 useQuery 的区别:
useQuery:建立订阅,数据变化时组件重渲染,必要时会自动发请求getQueryData:只读一次当前缓存值,不订阅、不请求、不触发渲染
全局错误处理
不需要每个 useQuery 都写 onError,在创建 QueryClient 时统一处理:
ts
const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error) => {
if (error instanceof UnauthorizedError) {
// 401 → 跳登录页
router.navigate({ to: '/login' });
return;
}
if (error instanceof BusinessError) {
// 业务错误 → 弹提示
notification.error(error.message);
return;
}
// 其他错误 → 通用提示
notification.error('网络异常,请稍后重试');
}
}),
});
如果某个请求不想走全局处理,在 meta 里打个标记就行:
ts
useQuery({
queryKey: ['some-data'],
queryFn: fetchSomeData,
meta: { skipGlobalError: true }, // 这个请求自己处理错误
});
二、TanStack Store:客户端状态管理
和 Redux、Zustand、Jotai 的区别
说到 React 状态管理,常见选项很多,先对比一下:
Redux(含 Redux Toolkit)
优点是生态成熟、Devtools 强大、适合大型团队统一规范。缺点是心智负担重------即便用了 RTK,一个状态需要定义 slice、action、selector,模板代码还是偏多。Redux 的更新是"全局 dispatch 一个 action",不够直接。
Zustand
比 Redux 轻很多,API 极简:
ts
const useStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}));
缺点是 selector 的精准订阅需要手动处理,写不好容易有不必要的重渲染。另外它是 hook-based,在 React 之外读取状态需要额外处理。
Jotai
原子化状态,每个 atom 独立管理,组合灵活。适合状态之间关系复杂、需要细粒度订阅的场景。但原子数量多了之后管理起来也有心智成本。
TanStack Store
ts
import { Store, useStore } from '@tanstack/store';
const counterStore = new Store({ count: 0 });
// 组件里:selector 决定订阅哪部分
function Counter() {
const count = useStore(counterStore, (s) => s.count);
return <div>{count}</div>;
}
// React 之外:直接读 .state
function getCount() {
return counterStore.state.count;
}
// 修改
counterStore.setState((s) => ({ ...s, count: s.count + 1 }));
TanStack Store 的设计哲学很简单:Store 是数据容器,useStore + selector 是订阅机制。没有 action、没有 reducer,直接改。
最大的特点是在 React 内外都能用同一个 store :React 组件用 useStore,非 React 环境读 .state,改状态用 setState。这让我们可以用一套数据流贯穿 React 组件和普通 JS 逻辑。
| 对比项 | Redux Toolkit | Zustand | Jotai | TanStack Store |
|---|---|---|---|---|
| 模板代码 | 中等 | 少 | 少 | 极少 |
| 精准订阅 | 需配合 reselect | 手动 | 天然原子化 | selector 函数 |
| React 外访问 | store.getState() |
useStore.getState() |
需 getDefaultStore() |
store.state |
| 包体积 | 较大 | 小 | 小 | 极小 |
| 适合场景 | 大型应用 | 通用 | 原子化状态 | 简单~中型 |
我们选 TanStack Store 的原因是:项目里状态的读写场景横跨 React 组件和各种命令处理逻辑,需要一个在两种场景里用法对称的方案。
queries / observe:读数据的两种模式
我们把读状态的方法按使用场景分成两组:
在 React 组件里(响应式):
ts
// 封装 useStore,组件订阅,状态变就重渲染
const editorQueries = {
useSelectedIds: () =>
useStore(editorStore, (s) => s.selectedIds),
useClipById: (id: string) =>
useStore(editorStore, (s) => s.clips[id]),
// selector 里可以做派生计算
useSortedClips: () =>
useStore(editorStore, (s) =>
Object.values(s.clips).sort((a, b) => a.startTime - b.startTime)
),
};
// 用法
function ClipItem({ id }) {
const clip = editorQueries.useClipById(id);
return <div>{clip.name}</div>;
}
在事件处理器 / 工具函数里(直接读):
ts
const editorObserve = {
getSelectedIds: () => editorStore.state.selectedIds,
getClipById: (id: string) => editorStore.state.clips[id],
};
// 用法
function handleKeyDown(e) {
if (e.key === 'Delete') {
const ids = editorObserve.getSelectedIds();
deleteClips(ids);
}
}
这个分法的好处:命名本身就是文档。useXxx 的前缀告诉你它是 Hook,只能在组件里用;getXxx 是普通函数,哪里都行。
写状态:setState
TanStack Store 更新状态只有一个 API:setState,接收一个函数,入参是当前 state,返回值是新 state。
ts
const todoStore = new Store({
items: [] as Todo[],
filter: 'all' as 'all' | 'active' | 'done',
});
// 添加一条
todoStore.setState((s) => ({
...s,
items: [...s.items, { id: Date.now(), text: '买菜', done: false }],
}));
// 修改某一条
todoStore.setState((s) => ({
...s,
items: s.items.map(item =>
item.id === targetId ? { ...item, done: true } : item
),
}));
// 切换过滤条件
todoStore.setState((s) => ({ ...s, filter: 'active' }));
setState 在 React 组件内外都能调,没有任何限制:
ts
// 组件里
function AddTodo() {
const handleAdd = () => {
todoStore.setState((s) => ({
...s,
items: [...s.items, { id: Date.now(), text: '新任务', done: false }],
}));
};
return <button onClick={handleAdd}>添加</button>;
}
// 普通函数里,完全一样
function markAllDone() {
todoStore.setState((s) => ({
...s,
items: s.items.map(item => ({ ...item, done: true })),
}));
}
封装 setter:让写操作有名字
直接暴露 setState 给组件用没什么问题,但随着状态变复杂,每次内联写更新逻辑会让组件很臃肿。常见的做法是把写操作封装成有名字的函数,集中管理:
ts
// store.ts
export const cartStore = new Store({
items: [] as CartItem[],
coupon: null as string | null,
});
// 封装写操作,像 API 一样暴露出去
export const cartActions = {
addItem(item: CartItem) {
cartStore.setState((s) => ({
...s,
items: [...s.items, item],
}));
},
removeItem(id: string) {
cartStore.setState((s) => ({
...s,
items: s.items.filter(i => i.id !== id),
}));
},
applyCoupon(code: string) {
cartStore.setState((s) => ({ ...s, coupon: code }));
},
clear() {
cartStore.setState((s) => ({ ...s, items: [], coupon: null }));
},
};
组件里调用就很干净:
tsx
function CartItem({ item }) {
return (
<div>
{item.name}
<button onClick={() => cartActions.removeItem(item.id)}>删除</button>
</div>
);
}
这只是一种组织方式,TanStack Store 本身没有强制要求。你也可以直接在组件里调 setState,取决于项目规模和团队习惯。
派生状态:在 selector 里算
不需要额外引入 computed/derived 概念,派生计算直接放在 useStore 的 selector 里:
ts
// 直接在 selector 里过滤、计算
const activeTodos = useStore(todoStore, (s) =>
s.items.filter(item => !item.done)
);
const stats = useStore(todoStore, (s) => ({
total: s.items.length,
done: s.items.filter(i => i.done).length,
active: s.items.filter(i => !i.done).length,
}));
需要注意的是,返回对象或数组时,每次 selector 执行都会生成新引用,TanStack Store 的浅比较会认为值变了,导致不必要的重渲染。可以用 shallow 比较函数解决:
ts
import { useStore, shallow } from '@tanstack/store';
// 第三个参数传入 shallow,对象/数组用浅比较
const stats = useStore(todoStore, (s) => ({
total: s.items.length,
done: s.items.filter(i => i.done).length,
}), shallow);
或者把派生计算拆出来,selector 只返回原始数据,组件里再算:
ts
const items = useStore(todoStore, (s) => s.items);
const activeTodos = useMemo(() => items.filter(i => !i.done), [items]);
三、TanStack Router:文件系统路由
和 React Router 比有什么不同
React Router v6 是目前最流行的路由库,TanStack Router 是后来者,但在类型安全和数据预加载上走得更远。
类型安全
React Router 的 useParams、useSearchParams 返回的是 string | undefined,你得自己转换类型:
tsx
// React Router
const { id } = useParams(); // id: string | undefined
const numericId = Number(id); // 手动转换,没有类型保证
// TanStack Router
const { id } = Route.useParams(); // id: number(根据路由定义自动推断)
TanStack Router 的类型是从路由定义一路推导下来的,params、search、loaderData 全部有类型,不需要手动断言。
数据预加载
TanStack Router 在路由层面原生支持数据预加载,和 TanStack Query 配合非常自然:
ts
// 路由文件里定义 loader
export const Route = createFileRoute('/users/$id')({
// 路由匹配时自动执行,数据准备好了再渲染
loader: ({ context: { queryClient }, params }) =>
queryClient.ensureQueryData(queries.users.detail(params.id)),
component: UserDetail,
});
function UserDetail() {
// loader 保证数据一定在缓存里,不会有 loading 状态
const { data: user } = useQuery(queries.users.detail(Route.useParams().id));
return <div>{user.name}</div>;
}
React Router 的 loader 也有类似功能,但和 React Query 配合需要额外处理,类型推断也没有 TanStack Router 顺畅。
文件系统路由
这是 TanStack Router 的另一个亮点。路由不需要手动配置,文件结构就是路由结构:
bash
routes/
index.tsx → /
about.tsx → /about
users/
index.tsx → /users
$id.tsx → /users/:id
$id.edit.tsx → /users/:id/edit
_layout.tsx → 布局路由(不影响 URL)
构建时插件自动生成路由树,新增路由只需要新建文件,不用去路由配置文件里注册。
| 对比项 | React Router v6 | TanStack Router |
|---|---|---|
| 类型安全 | 较弱,params 是 string | 完全类型推断 |
| 数据预加载 | 有 loader,但与 RQ 整合需配置 | 原生与 TanStack Query 集成 |
| 文件系统路由 | 无(需手动配置) | 插件支持,自动生成 |
| Search Params | 手动 parse/stringify | 类型安全,支持 schema 校验 |
| 生态成熟度 | 极成熟 | 较新,快速迭代 |
| 上手难度 | 低 | 中等(类型系统较复杂) |
React Router 的优势是生态和稳定性,TanStack Router 的优势是类型安全和与 TanStack Query 的深度集成。如果项目新起,且已经用了 TanStack Query,选 TanStack Router 可以获得最顺滑的开发体验。
在 Electron 里用 Hash History
在 Electron 里,页面通过 file:// 协议加载,使用 Browser History 会导致路径被操作系统按文件路径解析,出各种奇怪的问题。改用 Hash History 就没这个问题:
ts
import { createHashHistory, createRouter } from '@tanstack/react-router';
const router = createRouter({
routeTree,
history: createHashHistory(),
context: { queryClient }, // 把 queryClient 注入,loader 里可以用
});
URL 会变成 file:///path/to/app#/users/123 这种形式,# 后面的部分由前端路由处理,不会触发文件系统访问。
四、整体数据流
三个库协作起来,每层职责清晰:
markdown
用户操作
│
├─ 接口数据(服务器状态)
│ └─ useQuery / useMutation
│ ├─ 结果进 QueryClient 缓存
│ ├─ 所有订阅同一个 key 的组件自动更新
│ └─ 错误统一走 QueryCache.onError 处理
│
├─ 客户端状态变更
│ └─ store.setState() / 封装的 actions
│ └─ useStore selector 订阅的组件自动重渲染
│
└─ 路由切换
└─ TanStack Router 匹配路由
└─ loader 预加载数据到 QueryClient 缓存
└─ 组件渲染时数据已就绪,无 loading 闪烁
五、一些使用心得
query key 要设计成层级结构
ts
// 好的设计:层级清晰
['users'] // 所有 user 相关
['users', 'list', params] // user 列表
['users', 'detail', id] // 某个 user 详情
// 失效时可以精准控制范围
queryClient.invalidateQueries({ queryKey: ['users'] }); // 失效所有
queryClient.invalidateQueries({ queryKey: ['users', 'list'] }); // 只失效列表
selector 写法影响渲染性能
ts
// 差:每次渲染都返回新数组,浅比较失败,组件永远重渲染
useStore(store, (s) => Object.values(s.items));
// 好:在 selector 外部记住引用,或者用稳定的数据结构
useStore(store, (s) => s.itemIds); // 只订阅 id 数组,变化频率低
staleTime 不是越大越好
ts
// staleTime: 0(默认)→ 每次 mount 都重新请求,数据始终最新,但请求频繁
// staleTime: 3000 → 3 秒内不重新请求,适合变化不频繁的数据
// staleTime: Infinity → 永不过期,适合字典、枚举等几乎不变的数据
根据数据的更新频率设置合适的 staleTime,可以在"数据新鲜度"和"请求次数"之间取得平衡。