陌上人如玉
公子世无双

3.10 客户端状态与服务端状态

3.10 客户端状态与服务端状态

一句话:客户端状态(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' }));

八、面试延伸

  1. 「什么是客户端状态和服务端状态?为什么要区分?」
    客户端状态是「这个浏览器拥有的数据」(UI 状态、用户偏好、购物车草稿),服务端状态是「服务端拥有的数据在本地的快照」(接口返回的列表、详情)。区分的核心原因是服务端数据随时可能过期(被别的用户、设备或后台改掉),所以它需要的是一整套「缓存 + 失效 + 重新验证」机制,而不是简单的「存起来」。混在一起会导致大量手写样板代码与各种边界 bug。

  2. 「为什么服务端数据不该放 Redux?」
    因为你要自己重新实现一整套查询库的能力:缓存(同一接口多组件共享)、失效(什么时候算过期)、去重(同时请求只发一次)、重试与退避、焦点/网络重验证、分页缓存合并、乐观更新与回滚、内存回收。这些逻辑既繁琐又容易出错,而 TanStack Query / SWR 已经把它们做好了。Redux 只负责「客户端状态」是更清晰的划分。

  3. 「TanStack Query 的核心概念有哪些?」
    五个:① queryKey——查询的唯一标识(也是依赖声明,key 变化就会重新请求);② staleTime——数据「新鲜期」,期间不发新请求;③ gcTime——无订阅后缓存的保留时间(超过就回收);④ invalidateQueries——主动让某类查询失效并重新获取(通常用在 mutation 之后);⑤ useMutation——处理写操作,配合 onMutate 做乐观更新、onSettled 做失效。

  4. 「staleTime 和 gcTime 有什么区别?」
    staleTime(旧称 cacheTime 的对应概念不是它)控制「数据算不算新鲜」——在新湿期内,组件挂载/重新挂载不会触发新请求(直接用缓存);gcTime 控制「没有任何组件订阅后,缓存还能保留多久」——超过就彻底删除。所以:staleTime 短 → 频繁重新请求(数据更实时);gcTime 短 → 内存占用低但「返回页面要重新请求」。

  5. 「queryKey 为什么这么重要?」
    因为它同时承担三个职责:① 缓存标识(相同 key 共享同一份缓存);② 依赖声明(key 里的值变化 → 自动重新请求);③ 数据查找(setQueryData / invalidateQueries 都靠 key 定位)。所以规范是「把影响请求结果的所有参数都放进 key,且只放原始值」(不要放「每次渲染都新建的对象」)。如果参数没进 key,就会出现「参数变了但数据没更新」的 bug。

  6. 「用了 TanStack Query 还需要 Redux 吗?」
    看情况。两者解决不同问题:TanStack Query 管服务端状态,Redux/Zustand 管客户端状态。如果一个应用里「客户端全局状态很少」(只有主题、用户偏好),那么用 Context 或 Zustand 就够了,不需要 Redux;如果「客户端状态复杂、需要 DevTools 与规范」(如复杂的编辑器、多步骤业务流程),Redux 依然有价值。但服务端数据无论如何都不该放进 Redux(除非有特殊理由)。

  7. 「为什么说『数据放哪里』是架构问题?」
    因为状态的归属决定了:① 组件间的耦合度(放全局就人人可改,难以追踪);② 数据一致性(多处存放必然不同步);③ 性能(更新范围);④ 可测试性(依赖越少越易测)。一个清晰的划分能让代码「每个状态只有一个明确的拥有者与一套清晰的生命周期」——这是可维护性的基础,也是很多 React 项目重构的核心工作。

  8. 「遇到『服务端数据需要本地编辑』的场景怎么做?」
    三个原则:① 不要把服务端数据复制进 state/store(会产生第二份数据源);② 把「草稿」与「源数据」视为不同语义——表单组件内部管理草稿(useState(initialValue)),提交后通过 mutation 更新服务端,成功则 invalidateQueries 让列表刷新;③ 需要跨刷新保留的偏好(如「列表视图模式」)放单独的持久化 store 或 localStorage,并明确它「不属于服务端数据」。

一句话速记

区分「客户端状态(你拥有的:UI/偏好/草稿)」与「服务端状态(别人的快照,随时可能过期)」;服务端数据不要放 Redux/Zustand——那等于手写一遍查询库(缓存、失效、去重、重试、焦点重验证、分页、乐观更新、GC),直接用 TanStack Query / SWR;queryKey 是「缓存标识 + 依赖声明」(参数必须进 key、只放原始值),staleTime 管「新鲜期」、gcTime 管「回收时间」,mutation 后 invalidateQueries;URL 放「可分享状态」;服务端数据需要编辑时用「草稿」而不是复制一份。

赞(0) 打赏
未经允许不得转载:陌上寒 » 3.10 客户端状态与服务端状态

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫