
一句话:客户端状态(UI 状态、用户偏好、本地逻辑)和服务端状态(接口数据)在生命周期与需求上完全不同——后者应该交给 TanStack Query / SWR,而不是塞进 Redux/Zustand;把两类状态混在一起是 React 架构里最常见的错误之一。
一、两类状态的定义与差异
| 维度 | 客户端状态 | 服务端状态 |
|---|---|---|
| 拥有者 | 这个浏览器 | 服务端(本地只是缓存副本) |
| 例子 | 主题、展开态、购物车、表单草稿、多步流程 | 用户列表、详情、分页结果、统计数据 |
| 是否可能过期 | 不会(你就是权威) | ✅ 随时可能被别处修改 |
| 获取方式 | 同步(本地创建) | 异步(请求) |
| 生命周期 | 由组件/store 决定 | 需要缓存 + 失效策略 |
| 一致性 | 单一数据源即可 | 需要「重新验证(revalidate)」 |
| 是否需要 loading/error | 通常不需要 | ✅ 必须 |
一句话区分:
客户端状态 = 「你拥有的数据」
服务端状态 = 「别人拥有的数据的本地快照」
关键差异在于第七行「可能过期」:
服务端数据随时可能被别的用户、别的设备、后台任务改掉,
所以你需要一套「什么时候认为它过期、什么时候重新拉」的机制。
这正是 TanStack Query 存在的理由。
二、为什么服务端数据不该放 store
// ❌ 用 Redux 管服务端数据的典型代码(大量手写样板)
const slice = createSlice({
name: 'users',
initialState: { items: [], loading: false, error: null, lastFetched: null },
reducers: {
fetchStart(state) { state.loading = true; },
fetchSuccess(state, action) { state.items = action.payload; state.loading = false; state.lastFetched = Date.now(); },
fetchError(state, action) { state.error = action.payload; state.loading = false; },
},
});
// 然后你还要自己实现:
// ① 缓存(多个组件同时用 → 不能重复请求)
// ② 失效(什么时候算过期?)
// ③ 重试(失败了重试几次?退避策略?)
// ④ 焦点重验证(用户切回标签页要不要刷新?)
// ⑤ 分页/无限滚动的缓存合并
// ⑥ 乐观更新与回滚
// ⑦ 去重与竞态处理
// ⑧ 内存回收(什么时候清掉没人用的缓存?)
// ✅ 用 TanStack Query(上面 8 件事全部内置)
function useUsers() {
return useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
staleTime: 60_000, // 1 分钟内认为「新鲜」,不重新请求
gcTime: 5 * 60_000, // 5 分钟无订阅后清理缓存
retry: 3, // 失败自动重试 3 次(指数退避)
refetchOnWindowFocus: true, // 切回标签页时自动重新验证
});
}
function UserList() {
const { data, isLoading, error } = useUsers();
// ⭐ 三态、缓存、重试、去重、焦点重验证全部由库处理
}
| 自己用 store 实现 | TanStack Query |
|---|---|
| 手写 loading/error/data 三态 | 内置 |
| 手写缓存 key 与合并 | queryKey 自动管理 |
| 手写失效逻辑 | invalidateQueries |
| 手写重试 | retry + 指数退避 |
| 手写去重 | 自动(相同 key 共享请求) |
| 手写分页缓存 | useInfiniteQuery |
| 手写乐观更新 | onMutate + 回滚 |
| 手写内存回收 | gcTime |
一句话:把服务端数据放进 Redux,等于要自己重写一遍 TanStack Query。
三、服务端状态需要的能力(清单)
① 缓存(Cache)
同一 queryKey 的多个组件共享同一份数据与同一个请求
② 失效(Invalidation)
什么时候认为数据过期?
· 时间维度:staleTime(新鲜期)、gcTime(无订阅后的保留期)
· 事件维度:invalidateQueries(变更后主动失效)
· 焦点/网络维度:refetchOnWindowFocus、refetchOnReconnect
③ 去重与竞态(Dedup & Race)
同时发起的相同请求只发一次;过期响应不能被应用(见 7.2)
④ 重试(Retry)
网络失败自动重试 + 指数退避 + 可配置
⑤ 乐观更新(Optimistic Update)
立即反映预期结果,失败回滚(见 7.6)
⑥ 分页与无限滚动(Pagination)
缓存多页数据并正确合并(见 7.7)
⑦ 内存管理(GC)
没人订阅的缓存要能被回收,否则内存持续增长
⑧ 错误边界与重试 UI
让「失败」成为一种「状态」而不是「异常」
// TanStack Query 的完整用法示例(覆盖上面大部分能力)
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
// ① 查询
function useTodoList(userId: string) {
return useQuery({
queryKey: ['todos', { userId }], // ⭐ 结构化 key(依赖变化自动重新请求)
queryFn: ({ signal }) => api.getTodos(userId, { signal }),
staleTime: 30_000,
enabled: !!userId, // 条件执行
select: (data) => data.filter((t) => !t.deleted), // 转换数据(带记忆化)
});
}
// ② 变更 + 失效
function useAddTodo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: Partial<Todo>) => api.addTodo(body),
// 乐观更新(见 7.6)
onMutate: async (newTodo) => {
await queryClient.cancelQueries({ queryKey: ['todos'] });
const previous = queryClient.getQueryData<Todo[]>(['todos']);
queryClient.setQueryData<Todo[]>(['todos'], (old = []) => [...old, { ...newTodo, id: 'temp' } as Todo]);
return { previous };
},
onError: (_err, _vars, context) => {
queryClient.setQueryData(['todos'], context?.previous); // ⭐ 回滚
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] }); // ⭐ 无论成败都重新拉取
},
});
}
四、怎么划分(决策表)
| 数据 | 类型 | 该用什么 |
|---|---|---|
| 主题、语言 | 客户端(低频、全局) | Context 或 Zustand |
| 当前用户的权限配置 | 服务端(变化少) | TanStack Query(staleTime 设长) |
| 侧边栏展开状态 | 客户端(组件级) | useState |
| 购物车(本地未提交) | 客户端(全局) | Zustand / Redux |
| 商品列表 | 服务端 | TanStack Query |
| 商品详情 | 服务端 | TanStack Query(可用 queryKey 预取) |
| 表单输入值 | 客户端(表单) | react-hook-form / useState |
| 表单提交结果 | 服务端(变更) | useMutation |
| 页码、筛选项 | 客户端但可分享 | URL(useSearchParams) |
| 登录 token | 客户端但敏感 | HttpOnly Cookie(不要放 store/localStorage) |
| 未读消息数 | 服务端(频繁变化) | TanStack Query(refetchInterval 轮询) |
| 拖拽中的位置 | 客户端(高频) | useRef(避免重渲染) |
三条判断问题:
① 「这个数据的权威来源是服务端吗?」
→ 是 → TanStack Query(即使它变化很少,也享受缓存/失效能力)
② 「刷新页面后需要保留吗?」
→ 需要且应能分享 → URL
→ 需要但不该分享 → localStorage(或服务端草稿)
③ 「它会被多个不相关的组件用到吗?」
→ 是 → 全局方案(Query / Zustand / Context)
→ 否 → 就近 `useState`
五、混合场景的正确姿势
// 场景:一个「商品列表 + 筛选 + 购物车」的页面
// 三类状态分别归属正确的地方
function ProductPage() {
// ① URL 状态:筛选条件(可分享、可刷新)
const [searchParams, setSearchParams] = useSearchParams();
const category = searchParams.get('category') ?? 'all';
const page = Number(searchParams.get('page') ?? '1');
// ② 服务端状态:商品列表(TanStack Query 管缓存)
const { data, isLoading } = useQuery({
queryKey: ['products', { category, page }], // ⭐ 依赖变化 → 自动重新请求
queryFn: () => api.getProducts({ category, page }),
placeholderData: (prev) => prev, // 翻页时保留上一页(避免闪白)
});
// ③ 客户端状态:购物车(TanStack Query 之外,用 Zustand)
const addToCart = useCartStore((s) => s.addItem);
// ④ 客户端 UI 状态:排序方式(组件级)
const [sortBy, setSortBy] = useState<'price' | 'sales'>('price');
const sorted = useMemo(() => {
if (!data) return [];
return [...data.items].sort((a, b) => (sortBy === 'price' ? a.price - b.price : b.sales - a.sales));
}, [data, sortBy]);
return (
<>
<FilterBar value={category} onChange={(c) => setSearchParams({ category: c, page: '1' })} />
<SortSwitch value={sortBy} onChange={setSortBy} />
{isLoading ? <Skeleton /> : <ProductGrid items={sorted} onAdd={addToCart} />}
<Pagination page={page} onChange={(p) => setSearchParams({ category, page: String(p) })} />
</>
);
}
这个例子的四个归属:
① 筛选/页码 → URL(可分享、可刷新、可回退)
② 商品列表 → TanStack Query(缓存 + 自动重新请求)
③ 购物车 → Zustand(客户端全局、需要跨页面存活)
④ 排序方式 → 组件 useState(只有这个页面用)
→ 每个状态都在「最合适的地方」,没有一个是硬塞进全局 store 的
六、常见坑
// ① 把接口数据放进 Redux/Zustand,然后手写 loading/error/缓存
const useStore = create(() => ({ users: [], loading: false, error: null })); // ⚠️
// ② 把 queryKey 写成「随渲染变化」的对象(每次都是新 key → 每次都重新请求)
useQuery({ queryKey: ['products', { category, page, options: { a: 1 } }], ... }); // ⚠️
// ✅ 只放「真正影响请求的原始值」
// ③ 以为「staleTime 是缓存时间」
// → staleTime 是「新鲜期」(期间不重新请求);
// 缓存保留由 gcTime 控制(无订阅后多久清理)
// ④ 用 useQuery 发「不该缓存的请求」(如每次都要最新的实时数据)
// → 设 staleTime: 0 并 refetchInterval,或直接用手写请求
// ⑤ 在 useQuery 的 queryFn 里用「外部变量」而不是参数
const { data } = useQuery({ queryKey: ['x'], queryFn: () => api.get(id) }); // ❌ id 不在 key 里
// ✅ 把 id 放进 queryKey,并从 queryFn 的 context 里取
// ⑥ 用 mutation 修改数据后忘记 invalidate → 列表不更新
// ⑦ 在 onSuccess 里手动 setQueryData(其实用 invalidate 更简单可靠)
// ⑧ 把「用户输入」同步到 queryKey(每次输入都发请求)→ 应该防抖
// ⑨ 忘记 placeholderData / keepPreviousData → 翻页时列表闪白
// ⑩ 把 token 放进 localStorage 或 store(XSS 可读)→ 用 HttpOnly Cookie
// ⑪ 用「一个巨型 query」拉所有数据(应拆分成按需的查询)
// ⑫ 认为「用了 Query 就不需要全局状态了」(客户端状态仍需 store/Context)
// ⑤ 的正确写法:把参数放进 queryKey
function useProduct(id: string) {
return useQuery({
queryKey: ['product', id], // ⭐ 参数进 key
queryFn: ({ queryKey }) => api.getProduct(queryKey[1]), // ⭐ 从 key 里取
// 或直接闭包:queryFn: () => api.getProduct(id) —— 因为 id 在 key 里,闭包是安全的
});
}
七、服务器状态与客户端状态的「桥」
// 场景:服务端返回的数据需要「在客户端继续编辑」
// ❌ 错误做法:把服务端数据复制进客户端 store
function Bad() {
const { data } = useQuery({ queryKey: ['user'], queryFn: fetchUser });
const [name, setName] = useState(data?.name); // ⚠️ 两份数据源
}
// ✅ 正确做法一:以服务端数据为唯一来源,编辑时用「草稿」
function Good() {
const { data, isLoading } = useQuery({ queryKey: ['user'], queryFn: fetchUser });
if (isLoading) return <Skeleton />;
// 编辑表单自己管理「草稿」(明确区分:draft 与 source 是不同语义)
return <EditForm initialName={data!.name} onSubmit={(name) => updateUser({ name })} />;
}
function EditForm({ initialName, onSubmit }: { initialName: string; onSubmit: (name: string) => void }) {
const [draft, setDraft] = useState(initialName);
const mutation = useMutation({
mutationFn: onSubmit,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['user'] }),
});
return (
<>
<input value={draft} onChange={(e) => setDraft(e.target.value)} />
<button onClick={() => mutation.mutate(draft)} disabled={mutation.isPending}>保存</button>
</>
);
}
// ✅ 正确做法二:需要本地持久化的偏好,用单独的 store(不与服务端数据混)
const usePreferencesStore = create(persist(() => ({ listView: 'grid' }), { name: 'prefs' }));
八、面试延伸
- 「什么是客户端状态和服务端状态?为什么要区分?」
客户端状态是「这个浏览器拥有的数据」(UI 状态、用户偏好、购物车草稿),服务端状态是「服务端拥有的数据在本地的快照」(接口返回的列表、详情)。区分的核心原因是服务端数据随时可能过期(被别的用户、设备或后台改掉),所以它需要的是一整套「缓存 + 失效 + 重新验证」机制,而不是简单的「存起来」。混在一起会导致大量手写样板代码与各种边界 bug。 -
「为什么服务端数据不该放 Redux?」
因为你要自己重新实现一整套查询库的能力:缓存(同一接口多组件共享)、失效(什么时候算过期)、去重(同时请求只发一次)、重试与退避、焦点/网络重验证、分页缓存合并、乐观更新与回滚、内存回收。这些逻辑既繁琐又容易出错,而 TanStack Query / SWR 已经把它们做好了。Redux 只负责「客户端状态」是更清晰的划分。 -
「TanStack Query 的核心概念有哪些?」
五个:①queryKey——查询的唯一标识(也是依赖声明,key 变化就会重新请求);②staleTime——数据「新鲜期」,期间不发新请求;③gcTime——无订阅后缓存的保留时间(超过就回收);④invalidateQueries——主动让某类查询失效并重新获取(通常用在 mutation 之后);⑤useMutation——处理写操作,配合onMutate做乐观更新、onSettled做失效。 -
「
staleTime和gcTime有什么区别?」
staleTime(旧称cacheTime的对应概念不是它)控制「数据算不算新鲜」——在新湿期内,组件挂载/重新挂载不会触发新请求(直接用缓存);gcTime控制「没有任何组件订阅后,缓存还能保留多久」——超过就彻底删除。所以:staleTime短 → 频繁重新请求(数据更实时);gcTime短 → 内存占用低但「返回页面要重新请求」。 -
「
queryKey为什么这么重要?」
因为它同时承担三个职责:① 缓存标识(相同 key 共享同一份缓存);② 依赖声明(key 里的值变化 → 自动重新请求);③ 数据查找(setQueryData/invalidateQueries都靠 key 定位)。所以规范是「把影响请求结果的所有参数都放进 key,且只放原始值」(不要放「每次渲染都新建的对象」)。如果参数没进 key,就会出现「参数变了但数据没更新」的 bug。 -
「用了 TanStack Query 还需要 Redux 吗?」
看情况。两者解决不同问题:TanStack Query 管服务端状态,Redux/Zustand 管客户端状态。如果一个应用里「客户端全局状态很少」(只有主题、用户偏好),那么用 Context 或 Zustand 就够了,不需要 Redux;如果「客户端状态复杂、需要 DevTools 与规范」(如复杂的编辑器、多步骤业务流程),Redux 依然有价值。但服务端数据无论如何都不该放进 Redux(除非有特殊理由)。 -
「为什么说『数据放哪里』是架构问题?」
因为状态的归属决定了:① 组件间的耦合度(放全局就人人可改,难以追踪);② 数据一致性(多处存放必然不同步);③ 性能(更新范围);④ 可测试性(依赖越少越易测)。一个清晰的划分能让代码「每个状态只有一个明确的拥有者与一套清晰的生命周期」——这是可维护性的基础,也是很多 React 项目重构的核心工作。 -
「遇到『服务端数据需要本地编辑』的场景怎么做?」
三个原则:① 不要把服务端数据复制进 state/store(会产生第二份数据源);② 把「草稿」与「源数据」视为不同语义——表单组件内部管理草稿(useState(initialValue)),提交后通过 mutation 更新服务端,成功则invalidateQueries让列表刷新;③ 需要跨刷新保留的偏好(如「列表视图模式」)放单独的持久化 store 或localStorage,并明确它「不属于服务端数据」。
一句话速记
区分「客户端状态(你拥有的:UI/偏好/草稿)」与「服务端状态(别人的快照,随时可能过期)」;服务端数据不要放 Redux/Zustand——那等于手写一遍查询库(缓存、失效、去重、重试、焦点重验证、分页、乐观更新、GC),直接用 TanStack Query / SWR;
queryKey是「缓存标识 + 依赖声明」(参数必须进 key、只放原始值),staleTime管「新鲜期」、gcTime管「回收时间」,mutation 后invalidateQueries;URL 放「可分享状态」;服务端数据需要编辑时用「草稿」而不是复制一份。



最新评论
读过书不知道欧·亨利的人少。教科书上选文有
这小生活不错呀
不错,必须顶一下!
看着你还在坚持,很好
看来忙了也没时间更新博客了
NIce。学习了。。。。
网站不错!!!!
简洁实用,好文章!