大多数关于状态管理的讨论,都从工具开始:Redux、Zustand、XState,或者某个新出的原子化方案。但真正难的部分从来不是同步数据,而是回答一个更朴素的问题——用户此刻应该看到什么。

如果把一个界面当成一篇散文,状态就是它的叙事线。每一刻,读者只能读到一句话;而好的状态设计,会让这句话自然地接上上一句,不突兀,也不含糊。

状态是一条叙事线

我见过很多「布尔值丛林」:一个下载按钮同时挂着 isLoading、isDisabled、hasError、isHovered 和 hasStarted。每个变量单独看都合理,放在一起却没人能说清楚,当其中三个同时为真时,按钮到底该显示什么。

问题不在于变量多,而在于它们描述的是同一件事的不同侧面,却没有一个统一的叙事。

如果两个状态不可能同时出现,它们就不该是两个变量。

状态建模的第一条直觉

先写「读者会看到什么」

现在遇到交互状态,我会先不写类型,而是写一张界面矩阵——把用户可能看到的每一种画面列出来:

// 一个下载按钮,先只描述界面
idle    →  “下载”
loading →  “下载中…”(不可点击)
done    →  “再次下载”
failed  →  “重试”(旁边附一句原因)

然后才把它翻译成类型:

type DownloadState =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "done"; file: string }
  | { status: "failed"; reason: string };

这比五个布尔值更好的地方,不是类型安全,而是它逼你回答:这些画面之间能不能同时存在? 一旦不能,类型就会替你把不可能的组合作废。你不再需要写一堆防御性的 if,因为那些状态根本构造不出来。

派生状态不是状态

另一类常见的混乱,是把「可以算出来的东西」也存起来。列表过滤后的结果、是否全选、提交按钮是否可用——它们都是其它状态的函数,却经常被塞进 store,然后靠各种 effect 保持同步。

// 不需要存,只需要算
const filtered = items.filter(matchesQuery);
const allSelected =
  items.length > 0 && items.every((item) => item.selected);
const canSubmit = selectedCount > 0 && !isSubmitting;

每多存一个派生值,就多一个可能不同步的真相。同步代码写得越认真,越说明这个值一开始就不该被存下来。

当然,性能敏感的场景需要缓存。但缓存是优化,不是模型。先把唯一真相找出来,再决定要不要为它加一层记忆化。

删掉一半

每次 review 状态设计,我会依次问三个问题:

  1. 这个值能不能从别的状态推出来?如果能,删掉它。
  2. 它是否只服务一次交互,页面离开就该消失?如果是,它属于组件本地,不属于全局。
  3. 它是不是服务端数据的副本?如果是,把它交给请求缓存层,而不是 UI store。

三个问题问完,通常会删掉将近一半的字段。剩下的那部分,才值得被认真命名、建模、写测试。

状态的结尾

好状态像一个好句子:它知道自己在说什么,也知道什么时候该结束。它不会把所有可能性都摊在读者面前,而是替读者收束掉那些不重要的分支。

用户不会看到你的状态机,也不会关心你用的是什么库。他们只会觉得:这个页面很顺,点下去之后,事情就发生了该发生的变化。

这大概就是状态管理最好的结局——它安静到没有人注意到它存在。

写于 2026 年 8 月 返回全部文章 ↑