状态架构设计

状态架构设计——怎样区分本地状态、共享状态、URL 状态、服务端状态和流程状态,并用合适的 ownership、缓存和边界设计控制前端复杂度。

状态架构设计

状态为什么总是复杂度中心

前端里的很多 bug,表面看是页面问题、接口问题、组件问题。

往下追,常常会发现根子还是状态问题。

比如:

  • 数据到底以哪一份为准
  • 谁拥有修改权
  • 某个状态应该放局部还是放全局
  • 请求结果、缓存和界面状态有没有混在一起
  • 一个流程是“数据状态”,还是“步骤状态”

这些问题一旦处理得不清楚,系统就会慢慢出现几个典型症状:

  • 状态重复存储
  • 组件之间互相猜测彼此的意图
  • 一个改动牵出一串联动 bug
  • 请求、缓存、loading、error 被写成一团
  • 调试时看起来每一处都像问题点

所以状态架构不是“选个库”。

它首先是对复杂度做分类和 ownership 划分。

先把“状态”这个词讲清楚

状态可以简单理解为:

当前 UI、当前流程、当前数据,在某一时刻所依赖的那些可变事实。

但这句话不够用。

工程里更重要的是继续往下问:

  • 这个事实来自哪里
  • 谁负责更新
  • 谁消费它
  • 它多久失效
  • 它和其他状态之间是什么关系

这些问题,就是状态架构的核心。

现代前端里,最重要的不是“统一管理一切”

而是“先分清种类,再决定放在哪里”。

这也是为什么 React 官方一直强调:

  • 把状态放在真正需要它的地方
  • 能算出来的就不要重复存
  • 多个组件共享时再提升状态

如果一开始就把所有状态都收进一个全局容器,看起来统一,实际往往会更乱。

一个实用的状态分类框架

1. 局部 UI 状态

比如:

  • 弹窗开关
  • hover、active、selected
  • 表单的临时输入值
  • 某个列表的折叠状态

这类状态最常见的特点是:

  • 生命周期短
  • 影响范围小
  • 和具体组件结构绑定很深

通常最适合留在组件附近。

useStateuseReducer 或局部 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

如果你用一堆布尔值去拼:

  • isUploading
  • isSuccess
  • hasError
  • isRetrying

很快就会出现“理论上不该同时为真的状态组合”。

状态机的价值就在这里:

它把“当前状态”和“允许发生的转换”显式写出来。

常见反模式

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:把一个复杂流程改写成状态机图

练什么:

把布尔值堆叠转换为显式流程建模。

最小交付物:

一张状态图或一个最小状态机实现。

验收标准:

  • 能明确列出合法状态
  • 能明确列出状态转换事件

常见误区:

  • 只把旧逻辑换个语法,没真的收敛状态空间

延展阅读