REACT · Vol.III · LESSON 17 · 状态管理
客户端状态 vs 服务端状态:先分清再谈方案
从一个最常见的「烂摊子」说起
卷二收官时留了个问题:到底哪些东西值得放进 state?转型前端的 Java 后端工程师有个高频第一反应——接口返回什么,就往 state 或全局 store 里塞什么,把前端的全局状态当成数据库的一面镜子:
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 篇),再手写一堆同步逻辑,试图让它「看起来像真的」:
// 假设 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 双写不一致」:
- 双份真相:DB 一份、store 一份。别的页面、别的用户、别的端改了 DB,你的 store 一无所知——用户切个 Tab 回来,看到的还是十分钟前的名单;
- 同步逻辑野蛮生长:每个写操作都得记得更新 store 里的每一处相关副本,漏一处就是脏数据。写的人越多,烂得越快;
- 缓存价值归零:没有去重(两个组件同时挂载就打两次接口)、没有过期策略、没有后台静默刷新——缓存的红利一点没吃到,它的坑倒是一个不落全接了。
这等于放弃 Redis 的 TTL 和失效机制,在每段业务代码里手写 cache.put(...)、cache.remove(...),还得保证所有写路径一个不漏——你没这么写过后端,也知道这是灾难。正确思路是:声明「这段数据来自哪个查询」,缓存与失效交给基础设施。
前端对应的基础设施就是「服务端状态库」:TanStack Query、SWR。它们把「缓存 + 失效 + 重取 + 三态管理」打包成一行 useQuery(["users"], fetchUsers)——真源永远在服务端,本地只是可自动过期的缓存。这卷先不展开,第 33 篇专门讲它;在那之前,第 32 篇会先带你看裸写 useEffect 取数有多繁琐、有多少坑要自己填。
决策清单与方案光谱
拿到任何一份数据,先过一遍这份清单,方案基本就自己浮出来了:
- 真源在哪?服务端 → 它是服务端状态,别塞进全局 store,交给取数库(第 33 篇)。
- 只有一两个组件用?→ 组件内
useState/useReducer就够(第 4 篇)。别为了「将来可能复用」提前上全局方案——和后端「不要过早微服务化」是同一条朴素道理。 - 跨层级共享、但低频更新?→ Context(第 15 篇):主题、语言、当前登录人。
- 全局共享、更新频繁、组件树庞大?→ Zustand / Redux Toolkit(第 18、19 篇),享受切片订阅的细粒度更新。
- 刷新页面后还要在?→ 加持久化(第 22 篇),注意区分「要持久化的客户端状态」和「本该每次重取的服务端状态」。
把方案摆成一条光谱,本卷接下来的路线也就清晰了:
| 阵营 | 方案(由轻到重) | 适用 | 详见 |
|---|---|---|---|
| 客户端状态 | useState / useReducer → Context → Zustand / Redux Toolkit | UI 状态、交互草稿、全局偏好 | 第 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 · 评论