首页 / React 学习笔记 / 17

REACT · Vol.III · LESSON 17 · 状态管理

客户端状态 vs 服务端状态:先分清再谈方案

进阶核心#状态#架构

从一个最常见的「烂摊子」说起

卷二收官时留了个问题:到底哪些东西值得放进 state?转型前端的 Java 后端工程师有个高频第一反应——接口返回什么,就往 state 或全局 store 里塞什么,把前端的全局状态当成数据库的一面镜子:

UserList.tsx · 把接口数据「搬」进本地状态——先别急着学它
import { useEffect, useState } from "react";

function UserList() {
  const [users, setUsers] = useState<User[]>([]);

  useEffect(() => {
    fetch("/api/users")
      .then(r => r.json())
      .then(data => setUsers(data));   // 服务端数据 → 本地 state
  }, []);

  return (
    <ul>
      {users.map(u => <li key={u.id}>{u.name}</li>)}
    </ul>
  );
}

这段代码能跑,但往下写之前,先问自己一个问题:users 这份数据,真源(source of truth)是谁?改它的权力在谁手上?——在数据库,在后端,在「另一个办公室里同样正在删用户的运营同事」手上。前端手里的 users,只是某一秒查询结果的一份拷贝。

再看另一个状态:「列表顶部的筛选 Tab,当前选中『在职』」。这份数据的真源呢?就是浏览器里的这一次点击——前端说是什么,就是什么。

这两种状态的性质天差地别,而 90% 的「状态管理痛苦」,都源于把它们混进了同一个口袋。

后端类比:你早就会区分「缓存」和「内存变量」

写后端时,你从来不会把一张表的数据整个搬进 JVM 内存、再手工维护它和 DAO 背后那份数据库的一致性——你知道那叫缓存,要设计失效策略、过期时间、写路径同步。缓存的本质是「不是真源的副本」。而 session 里的登录信息、方法里的临时对象,你叫它们状态:进程私有,随生随灭。到了前端,这两类东西却经常被一律塞进同一个 useState

客户端状态:浏览器内存里的「会话数据」

客户端状态:真源在浏览器里、由用户交互产生的状态。你天天打交道的就是它们:

  • 侧边栏折叠了没有、弹窗开没开;
  • 当前选中的 Tab、列表的排序方式(纯 UI 层面);
  • 输入到一半的搜索关键词、表单草稿;
  • 向导走到第几步、抽屉展开到哪一栏。

它的四个特征:

维度客户端状态的表现
所有权浏览器——前端说了算,setState 是唯一合法变更途径
更新来源用户交互(点击、输入、拖拽)
一致性要求界面内部自洽即可,不存在「别人偷偷改数据」
缓存价值几乎没有——刷新丢了就丢了,无伤大雅
后端类比:内存里的会话对象

客户端状态像一个 Service 的私有字段、或 session 里的一个 POJO:进程私有、变更可预期、崩了也不影响别人。管理这类状态的核心诉求是「组织」——怎么让它跨组件共享得优雅。这正是卷二讲过的(第 14 篇状态提升、第 15 篇 Context),也是接下来第 18、19 篇(Zustand / Redux Toolkit)要继续回答的问题。

服务端状态:一份带缓存的「读模型副本」

服务端状态:真源在服务端(数据库或其他系统)、前端持有的只是某次查询结果快照的状态。用户列表、订单详情、商品库存、消息未读数……凡是「接口返回的」,都是这一类。

把两类状态放进一张表,差异一目了然:

维度客户端状态服务端状态
真源(所有权)浏览器服务端数据库
更新来源用户交互后端写入——包括其他用户、其他端的修改
一致性要求界面自洽即可本地副本随时可能过期,需要失效与重取
缓存价值几乎没有很高——重复请求浪费带宽,值得去重与缓存
固有复杂度同步的,赋值即生效异步的——天然伴随 loading / error / success 三态(第 34 篇)
典型例子弹窗、Tab、草稿用户列表、订单详情、库存数
后端类比:带缓存的数据库读模型

服务端状态就像 MyBatis 二级缓存或 Redis 里缓存的那份查询结果:副本 ≠ 真源。缓存界的老三样问题它一个不少——过期(数据旧了怎么办)、失效(DB 改了缓存知道吗)、并发(两个请求同时打同一个 key)。后端工程师对这套再熟不过;而前端拿个 useState 一存,相当于用一个没有任何失效策略的 HashMap 当缓存

注意表格里「其他用户、其他端」那一行:你正看着的用户列表,可能此刻正被同事在后台删掉两个。客户端状态永远不会遇到「第三方悄悄改数据」,而服务端状态的每一秒都要面对它——这就是它需要一整套不同管理思路的原因。

反模式现场:双份真相,手工同步

分清两类状态之后,就能精确描述那个最常见的反模式了——把服务端状态当客户端状态管理:接口数据搬进全局 store(Context、Zustand、还没讲到的 Redux 都一样,第 19 篇),再手写一堆同步逻辑,试图让它「看起来像真的」:

❌ 反模式 · fetch 进 store + 手工维护一致性
// 假设 users 已存进全局 store
function UserAdmin() {
  const users = useStore(s => s.users);        // 本地副本
  const refresh = useStore(s => s.setUsers);

  useEffect(() => {
    fetchUsers().then(refresh);                // 进页面拉一次
  }, [refresh]);

  function handleDelete(id: number) {
    deleteUser(id).then(() => {
      // ❌ 手工同步:删除接口成功后,记得改本地那份
      useStore.setState(s => ({
        users: s.users.filter(u => u.id !== id),
      }));
    });
  }
  // 还有编辑、新增、批量操作……每一处都要「记得同步」
}

问题不在「写得糙」,而在结构上就错了——这是前端版的「缓存与 DB 双写不一致」

  1. 双份真相:DB 一份、store 一份。别的页面、别的用户、别的端改了 DB,你的 store 一无所知——用户切个 Tab 回来,看到的还是十分钟前的名单;
  2. 同步逻辑野蛮生长:每个写操作都得记得更新 store 里的每一处相关副本,漏一处就是脏数据。写的人越多,烂得越快;
  3. 缓存价值归零:没有去重(两个组件同时挂载就打两次接口)、没有过期策略、没有后台静默刷新——缓存的红利一点没吃到,它的坑倒是一个不落全接了。
后端类比:手写 cache.put / cache.remove

这等于放弃 Redis 的 TTL 和失效机制,在每段业务代码里手写 cache.put(...)cache.remove(...),还得保证所有写路径一个不漏——你没这么写过后端,也知道这是灾难。正确思路是:声明「这段数据来自哪个查询」,缓存与失效交给基础设施

前端对应的基础设施就是「服务端状态库」:TanStack Query、SWR。它们把「缓存 + 失效 + 重取 + 三态管理」打包成一行 useQuery(["users"], fetchUsers)——真源永远在服务端,本地只是可自动过期的缓存。这卷先不展开,第 33 篇专门讲它;在那之前,第 32 篇会先带你看裸写 useEffect 取数有多繁琐、有多少坑要自己填。

决策清单与方案光谱

拿到任何一份数据,先过一遍这份清单,方案基本就自己浮出来了:

  1. 真源在哪?服务端 → 它是服务端状态,别塞进全局 store,交给取数库(第 33 篇)。
  2. 只有一两个组件用?→ 组件内 useState / useReducer 就够(第 4 篇)。别为了「将来可能复用」提前上全局方案——和后端「不要过早微服务化」是同一条朴素道理。
  3. 跨层级共享、但低频更新?→ Context(第 15 篇):主题、语言、当前登录人。
  4. 全局共享、更新频繁、组件树庞大?→ Zustand / Redux Toolkit(第 18、19 篇),享受切片订阅的细粒度更新。
  5. 刷新页面后还要在?→ 加持久化(第 22 篇),注意区分「要持久化的客户端状态」和「本该每次重取的服务端状态」。
这份数据的真源在哪? 真源:服务端数据库 服务端状态 本地只是副本:要缓存 / 失效 / 重取 TanStack Query / SWR 第 33 篇展开 真源:浏览器交互 客户端状态 按共享范围逐级升级 useState / useReducer Context(低频跨层) Zustand / Redux(高频)
图 1先问真源在哪,再选方案:服务端状态走取数库,客户端状态按共享范围逐级升级。

把方案摆成一条光谱,本卷接下来的路线也就清晰了:

阵营方案(由轻到重)适用详见
客户端状态useState / useReducer → Context → Zustand / Redux ToolkitUI 状态、交互草稿、全局偏好第 18、19 篇
服务端状态TanStack Query / SWR(裸 useEffect 是它的「手写 JDBC」阶段)接口数据、列表、详情第 33 篇

一句话记住选型哲学:客户端状态,选「谁组织得优雅」;服务端状态,选「谁缓存得专业」。两者之间没有竞争关系——成熟的项目里它们并存,各管各的口袋。

核心要点
  • 先问真源:在服务端 → 服务端状态,交给取数库管缓存与失效;在浏览器 → 客户端状态,按共享范围选工具。
  • 接口数据塞进全局 store 再手工同步,等于前端版「缓存与 DB 双写不一致」——结构上就错了,不是写得糙。
  • 方案光谱:useState/useReducer → Context → Zustand(客户端阵营);TanStack Query / SWR(服务端阵营)。

本章回顾

这一篇没引入任何新 API,只立了一个判断标准:先问真源在哪,再谈方案。客户端状态是浏览器内存里的会话数据,求「组织得优雅」;服务端状态是带缓存的读模型副本,求「缓存得专业」。把接口数据搬进全局 store 手工同步,就是前端版的「缓存与 DB 双写不一致」——双份真相,迟早对不上。

下一篇正式请出客户端阵营的第一位主角——第 18 篇 Zustand:为什么在 Redux 统治多年之后,大家转头爱上了这个只有一 KB 出头的小库?你会发现它的心智模型,意外地接近你熟悉的「一个带订阅能力的全局 Map」。

Comments · 评论