状态架构设计
状态为什么总是复杂度中心
前端里的很多 bug,表面看是页面问题、接口问题、组件问题。
往下追,常常会发现根子还是状态问题。
比如:
- 数据到底以哪一份为准
- 谁拥有修改权
- 某个状态应该放局部还是放全局
- 请求结果、缓存和界面状态有没有混在一起
- 一个流程是“数据状态”,还是“步骤状态”
这些问题一旦处理得不清楚,系统就会慢慢出现几个典型症状:
- 状态重复存储
- 组件之间互相猜测彼此的意图
- 一个改动牵出一串联动 bug
- 请求、缓存、loading、error 被写成一团
- 调试时看起来每一处都像问题点
所以状态架构不是“选个库”。
它首先是对复杂度做分类和 ownership 划分。
先把“状态”这个词讲清楚
状态可以简单理解为:
当前 UI、当前流程、当前数据,在某一时刻所依赖的那些可变事实。
但这句话不够用。
工程里更重要的是继续往下问:
- 这个事实来自哪里
- 谁负责更新
- 谁消费它
- 它多久失效
- 它和其他状态之间是什么关系
这些问题,就是状态架构的核心。
现代前端里,最重要的不是“统一管理一切”
而是“先分清种类,再决定放在哪里”。
这也是为什么 React 官方一直强调:
- 把状态放在真正需要它的地方
- 能算出来的就不要重复存
- 多个组件共享时再提升状态
如果一开始就把所有状态都收进一个全局容器,看起来统一,实际往往会更乱。
一个实用的状态分类框架
1. 局部 UI 状态
比如:
- 弹窗开关
- hover、active、selected
- 表单的临时输入值
- 某个列表的折叠状态
这类状态最常见的特点是:
- 生命周期短
- 影响范围小
- 和具体组件结构绑定很深
通常最适合留在组件附近。
用 useState、useReducer 或局部 custom hook 往往就够了。
2. 共享客户端状态
比如:
- 当前登录用户的前端会话信息
- 主题、语言、布局偏好
- 多个页面共用的筛选条件
- 一组需要跨组件同步的临时编辑结果
这类状态的重点不是“是否全局”。
而是:
是否有多个相距较远的消费者,需要共享同一份权威状态。
3. 服务端状态
这是最容易被误管的一类。
服务端状态指的是:
源头在服务端,本地只是读取、缓存、刷新、失效和展示。
例如:
- 用户资料
- 订单列表
- 查询结果
- 权限配置
这类数据的真正权威来源不在前端。
所以把它们直接塞进通用全局 store,往往会把缓存、刷新、重试、失效策略都变成手写逻辑。
这也是 TanStack Query、SWR 这类工具长期重要的原因。
它们解决的不是“换一种 fetch 写法”。
而是帮你把服务端状态的生命周期管理系统化。
4. URL 状态
很多团队低估它。
实际上,搜索词、筛选条件、分页、排序、tab、视图模式,经常都应该视为 URL 状态。
因为这类状态天然有几个特点:
- 需要分享链接
- 需要刷新后保留
- 需要前进后退可恢复
如果把这类状态只放内存,不放 URL,用户体验和可追踪性通常都会变差。
5. 流程状态
流程状态和普通数据状态不完全一样。
例如:
- 多步骤表单
- 支付流程
- 任务审批流程
- 聊天发送中的排队、重试、确认状态
这类场景的重点不是“当前值是什么”。
而是“系统现在处在哪个阶段、允许往哪里走、哪些事件合法”。
这时状态机思维往往比普通 store 更有帮助。
状态架构里最重要的词:ownership
ownership 可以直译成“归属”。
简单说,就是:
这份状态到底归谁管。
很多系统混乱,不是因为没有状态库。
而是因为 ownership 模糊。
比如一个接口数据:
- 页面组件改一份
- store 里存一份
- 表单默认值再拷一份
- URL 里又映射一份
这样一来,任何改动都可能让几份状态不同步。
所以状态设计的第一原则通常不是“方便取用”。
而是“每份状态只保留一个权威来源”。
React 官方为什么一直强调“能算出来的不要存”
因为重复状态是 bug 温床。
一个经典例子:
- 你已经有
items - 又额外存一个
filteredItems
表面看是为了方便。
实际很容易出现:
- 原始数据更新了
- 过滤结果没同步
- 某个 effect 又在补救同步
最后系统进入“为了解决状态不同步而增加更多同步逻辑”的循环。
更稳妥的做法通常是:
把真正的输入状态存下来。
例如:
- 原始数据
- 当前筛选条件
然后在渲染时推导出结果。
这就是“最小状态”原则。
服务端状态为什么不该被粗暴塞进全局 store
因为它跟本地状态的关注点不同。
服务端状态通常要处理:
- 缓存
- 失效
- 后台刷新
- 重试
- 去重
- 请求竞争
- 乐观更新
这些都不是普通 setState 的强项。
如果团队把服务端状态都当成“取回来后放进 store”,通常很快就会开始补各种额外逻辑。
最后其实是在重复发明 query cache。
所以一个成熟的状态架构,往往会明确分层:
- 本地交互状态,用组件状态或轻量 store
- 服务端状态,用 query 层
- 流程状态,用 reducer 或状态机
Redux、Zustand、Jotai、XState 到底怎么选
不要把它们理解成“功能一模一样、只是 API 风格不同”。
更实用的看法是:
Redux Toolkit
适合:
- 团队规模较大
- 状态流转需要明确约束
- DevTools、可追踪性、规范化 reducer 有价值
它的重点不只是 store。
而是围绕 predictable state flow 建立一套团队共同约束。
Zustand
适合:
- 需要轻量共享状态
- 希望减少样板代码
- 不需要特别强的状态流纪律
它很好用,但也容易因为太自由而让边界变松。
Jotai
适合:
- 状态天然可拆成更细粒度原子
- 希望消费关系随依赖自然展开
它在某些组件密集、依赖细碎的场景里很顺手。
XState / 状态机
适合:
- 流程复杂
- 事件驱动明显
- 合法状态和非法状态需要明确区分
比如支付、审批、重试队列、机器人对话、实时协作连接状态。
如果你只是想存几个开关,状态机当然太重。
但如果你已经在用很多 if/else 维护复杂流程,状态机常常能把混乱变成显式规则。
不要把“选库”当成状态架构本身
这是很多团队会踩的坑。
状态架构真正先要回答的是:
- 哪些状态存在
- 谁拥有它们
- 依赖关系是什么
- 生命周期是什么
- 哪些可以推导
- 哪些需要持久化
- 哪些需要被 URL 表达
库只是实现这些判断的工具。
如果这些问题没想清楚,换任何库都只是换一种混乱。
URL 状态为什么越来越值得重视
现代 Web 应用里,很多页面已经不是单纯展示。
它们往往带有:
- 筛选
- 搜索
- 排序
- 多视图切换
- 深链接需求
这时 URL 不只是导航。
它还是状态同步媒介。
把适合暴露给链接和历史记录的状态放进 URL,有几个明显好处:
- 可分享
- 可恢复
- 可观测
- 更接近 Web 平台原生行为
当然,也不是所有状态都该放 URL。
像临时 hover、未提交表单草稿、复杂富文本本地编辑细节,通常就不适合直接塞进去。
表单状态和服务端状态为什么要分开
这也是一个非常常见的混淆点。
表单有自己的生命周期:
- 默认值
- 编辑中
- 校验
- 提交中
- 提交失败
- 重置
如果把这些和接口缓存状态混在一起,边界会很乱。
一个更清晰的模式通常是:
- query 层负责拿到初始数据
- 表单层负责本地编辑与校验
- 提交后再协调服务端状态刷新
这样每一层都更清楚。
流程状态为什么适合用 reducer 或状态机来想
当一个交互过程有明显事件顺序时,只用零散布尔值很容易出事。
例如一个上传流程:
- idle
- selecting
- uploading
- success
- error
- retrying
如果你用一堆布尔值去拼:
isUploadingisSuccesshasErrorisRetrying
很快就会出现“理论上不该同时为真的状态组合”。
状态机的价值就在这里:
它把“当前状态”和“允许发生的转换”显式写出来。
常见反模式
1. 把一切都放全局
看似统一,实际上会扩大影响面,增加耦合。
2. 同一份事实存多份
一旦需要靠 effect 去同步两份状态,通常就要警惕。
3. 把服务端状态当本地状态管
最后会手写很多缓存和重试逻辑。
4. 状态位置随着“哪里方便取”不停漂移
这会让 ownership 越来越模糊。
5. 把流程问题写成数据问题
复杂流程被塞进零散字段后,可读性和可验证性都会变差。
怎么评估当前状态架构是不是在失控
可以观察一些很实用的信号:
- 新需求经常要改多个不相关组件才能稳定
- effect 数量越来越多,而且很多在做同步
- 同一个接口数据在多个地方重复缓存
- 页面刷新的时候状态恢复逻辑很混乱
- 调试时很难回答“这份数据以哪里为准”
一旦这些信号开始变密,就说明应该回头看状态边界了。
中国互联网语境下,为什么状态架构经常更复杂
因为很多业务场景天然更重:
- 多 tab 与多筛选组合
- 大量表单与审批流
- 活动页、运营页状态变化快
- Hybrid、Mini Program、WebView 共存
- 页面交互和服务端返回节奏都很密集
这会放大几个问题:
- URL 状态是否设计清楚
- 服务端状态与本地交互状态是否分层
- 跨容器、跨端状态同步是否有边界
所以在国内复杂业务里,状态架构常常不是“工具选型题”。
它更像“业务复杂度治理题”。
海外产品语境下,哪些点更突出
在很多 SaaS、协作工具、内容产品里,状态架构会更频繁地碰到:
- 离线与重连
- 实时协作
- URL 深链接
- 多角色权限
- server/client boundary
所以 query cache、同步策略、连接状态、状态机和 a11y 反馈状态这些点会更加突出。
这类主题很适合做表达训练
因为它非常容易被讲成“我会 Redux / 我会 Zustand”。
但真正 senior 的表达重点不是工具名。
而是:
- 你怎么分类状态
- 你怎么判断 ownership
- 你怎么分清本地状态和服务端状态
- 你怎么避免重复状态
- 流程复杂时你怎么建模
能把这些讲清楚,才说明你理解的是架构,而不只是 API。
建议实践
实践 1:做一次状态盘点
练什么:
学会把页面里的状态按类型拆开。
最小交付物:
一张状态清单,至少区分局部状态、共享状态、URL 状态、服务端状态。
验收标准:
- 每份状态都有 ownership 说明
- 能指出哪些状态其实可以推导
常见误区:
- 把“用哪个库”当成盘点结果
实践 2:把一个页面从手写 fetch 改成 query 层
练什么:
理解服务端状态与本地状态的分层。
最小交付物:
一个使用 TanStack Query 或 SWR 的最小页面。
验收标准:
- 有清晰的 loading、error、retry、refresh 处理
- 不再手写重复缓存逻辑
常见误区:
- query 数据进来后又复制进全局 store
实践 3:把一个复杂流程改写成状态机图
练什么:
把布尔值堆叠转换为显式流程建模。
最小交付物:
一张状态图或一个最小状态机实现。
验收标准:
- 能明确列出合法状态
- 能明确列出状态转换事件
常见误区:
- 只把旧逻辑换个语法,没真的收敛状态空间