我们是怎么用 TanStack 全家桶的

42 阅读3分钟

这篇文章讲的是 TanStack Query、TanStack Store、TanStack Router 三个库的实际使用思路,结合真实业务场景说明"为什么这么用",以及跟其他同类方案比有什么不同。


先说说这三个包是干什么的

"@tanstack/react-query": "^5.x",
"@tanstack/react-store": "^0.x",
"@tanstack/react-router": "^1.x"

分工非常清晰:

  • react-query:管"服务器状态",也就是从接口拿来的数据
  • react-store:管"客户端状态",纯前端的 UI 状态
  • react-router:管路由,基于文件系统自动生成

一、TanStack Query:接口数据管理

从"手写请求"说起

大多数人第一次写数据请求,差不多是这样:

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

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 QuerySWR
Mutation 支持useMutation,完善需要手动实现或用第三方
开发者工具官方 Devtools,可视化缓存状态无官方 Devtools
缓存控制粒度极细,可以按 key 精准失效相对粗粒度
重试策略自定义函数,按错误类型决定配置项较少
包体积较大更小
使用场景复杂应用,需要精细控制简单场景,快速上手

如果项目比较简单、主要是 GET 请求展示数据,SWR 完全够用。但我们的项目涉及大量 mutation、复杂的缓存失效逻辑、自定义错误处理,TanStack Query 的控制粒度更合适。

Query Key:数据的"身份证"

queryKey 是 TanStack Query 的核心概念。它相当于每份缓存数据的身份证,同一个 key 对应同一份数据。

// 静态 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'] 相关的缓存失效,列表会自动刷新:

const deleteMutation = useMutation({
  mutationFn: deletePost,
  onSuccess: () => {
    // 精准失效:以 ['posts'] 开头的所有缓存都失效
    queryClient.invalidateQueries({ queryKey: ['posts'] });
  }
});

用 query-key-factory 统一管理 key

项目大了之后,key 散落在各处很难维护。我们用 @lukemorales/query-key-factory 把所有 key 集中管理:

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);

在组件里:

// 静态 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 做成单例,在任何地方都能访问:

// 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 时统一处理:

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 里打个标记就行:

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 极简:

const useStore = create((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
}));

缺点是 selector 的精准订阅需要手动处理,写不好容易有不必要的重渲染。另外它是 hook-based,在 React 之外读取状态需要额外处理。

Jotai

原子化状态,每个 atom 独立管理,组合灵活。适合状态之间关系复杂、需要细粒度订阅的场景。但原子数量多了之后管理起来也有心智成本。

TanStack Store

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 ToolkitZustandJotaiTanStack Store
模板代码中等极少
精准订阅需配合 reselect手动天然原子化selector 函数
React 外访问store.getState()useStore.getState()getDefaultStore()store.state
包体积较大极小
适合场景大型应用通用原子化状态简单~中型

我们选 TanStack Store 的原因是:项目里状态的读写场景横跨 React 组件和各种命令处理逻辑,需要一个在两种场景里用法对称的方案。

queries / observe:读数据的两种模式

我们把读状态的方法按使用场景分成两组:

在 React 组件里(响应式):

// 封装 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>;
}

在事件处理器 / 工具函数里(直接读):

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。

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 组件内外都能调,没有任何限制:

// 组件里
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 给组件用没什么问题,但随着状态变复杂,每次内联写更新逻辑会让组件很臃肿。常见的做法是把写操作封装成有名字的函数,集中管理:

// 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 }));
  },
};

组件里调用就很干净:

function CartItem({ item }) {
  return (
    <div>
      {item.name}
      <button onClick={() => cartActions.removeItem(item.id)}>删除</button>
    </div>
  );
}

这只是一种组织方式,TanStack Store 本身没有强制要求。你也可以直接在组件里调 setState,取决于项目规模和团队习惯。

派生状态:在 selector 里算

不需要额外引入 computed/derived 概念,派生计算直接放在 useStore 的 selector 里:

// 直接在 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 比较函数解决:

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 只返回原始数据,组件里再算:

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,你得自己转换类型:

// 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 配合非常自然:

// 路由文件里定义 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 的另一个亮点。路由不需要手动配置,文件结构就是路由结构:

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 v6TanStack 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 就没这个问题:

import { createHashHistory, createRouter } from '@tanstack/react-router';

const router = createRouter({
  routeTree,
  history: createHashHistory(),
  context: { queryClient }, // 把 queryClient 注入,loader 里可以用
});

URL 会变成 file:///path/to/app#/users/123 这种形式,# 后面的部分由前端路由处理,不会触发文件系统访问。


四、整体数据流

三个库协作起来,每层职责清晰:

用户操作
  │
  ├─ 接口数据(服务器状态)
  │    └─ useQuery / useMutation
  │         ├─ 结果进 QueryClient 缓存
  │         ├─ 所有订阅同一个 key 的组件自动更新
  │         └─ 错误统一走 QueryCache.onError 处理
  │
  ├─ 客户端状态变更
  │    └─ store.setState() / 封装的 actions
  │         └─ useStore selector 订阅的组件自动重渲染
  │
  └─ 路由切换
       └─ TanStack Router 匹配路由
            └─ loader 预加载数据到 QueryClient 缓存
                 └─ 组件渲染时数据已就绪,无 loading 闪烁

五、一些使用心得

query key 要设计成层级结构

// 好的设计:层级清晰
['users']                    // 所有 user 相关
['users', 'list', params]    // user 列表
['users', 'detail', id]      // 某个 user 详情

// 失效时可以精准控制范围
queryClient.invalidateQueries({ queryKey: ['users'] });          // 失效所有
queryClient.invalidateQueries({ queryKey: ['users', 'list'] });  // 只失效列表

selector 写法影响渲染性能

// 差:每次渲染都返回新数组,浅比较失败,组件永远重渲染
useStore(store, (s) => Object.values(s.items));

// 好:在 selector 外部记住引用,或者用稳定的数据结构
useStore(store, (s) => s.itemIds); // 只订阅 id 数组,变化频率低

staleTime 不是越大越好

// staleTime: 0(默认)→ 每次 mount 都重新请求,数据始终最新,但请求频繁
// staleTime: 3000   → 3 秒内不重新请求,适合变化不频繁的数据
// staleTime: Infinity → 永不过期,适合字典、枚举等几乎不变的数据

根据数据的更新频率设置合适的 staleTime,可以在"数据新鲜度"和"请求次数"之间取得平衡。