我们是怎么用 TanStack 全家桶的

这篇文章讲的是 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>;
}

写一次还好,写多了就会发现几个问题一直在重复出现:

  1. 同一个接口在两个组件里各请求了一次,明明数据一样,却发了两次请求
  2. 切换路由回来,数据已经过期,但没有任何机制去刷新
  3. loading / error / data 这套模板,每个接口都得写一遍
  4. 组件卸载后 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;
}

getQueryDatauseQuery 的区别:

  • 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 的 useParamsuseSearchParams 返回的是 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 的类型是从路由定义一路推导下来的,paramssearchloaderData 全部有类型,不需要手动断言。

数据预加载

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,可以在"数据新鲜度"和"请求次数"之间取得平衡。

相关推荐
小小善后师2 小时前
告别周报烦恼:我用 200 行代码打造了一个 AI 工时助手
前端
我只是来分享的2 小时前
Js也能写外挂?8 行代码改掉《植物大战僵尸》的阳光值!对于js来说超越调用大漠超越调用memory.js
javascript
源远流长jerry2 小时前
NFV(网络功能虚拟化):重塑未来网络架构的革命性技术
linux·服务器·网络·架构
FreeBuf_2 小时前
Claude浏览器扩展漏洞允许通过任意网站实现零点击XSS提示注入
前端·网络·xss
AlunYegeer2 小时前
【JAVA】网关的管理原理和微服务的Interceptor区分
java·服务器·前端
sensen_kiss2 小时前
CAN302 电子商务技术 Pt.2 深入了解HTML和CSS
前端·css·学习·html
说实话起个名字真难啊2 小时前
前端JS审计:渗透测试的“破局之钥”
开发语言·前端·javascript·测试工具
吴声子夜歌2 小时前
TypeScript——编译器和编译选项
前端·javascript·typescript
herogus丶2 小时前
【Chrome插件】页面自动化助手使用介绍
前端·chrome·自动化