大多数关于状态管理的讨论,都从工具开始: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 状态设计,我会依次问三个问题:
- 这个值能不能从别的状态推出来?如果能,删掉它。
- 它是否只服务一次交互,页面离开就该消失?如果是,它属于组件本地,不属于全局。
- 它是不是服务端数据的副本?如果是,把它交给请求缓存层,而不是 UI store。
三个问题问完,通常会删掉将近一半的字段。剩下的那部分,才值得被认真命名、建模、写测试。
状态的结尾
好状态像一个好句子:它知道自己在说什么,也知道什么时候该结束。它不会把所有可能性都摊在读者面前,而是替读者收束掉那些不重要的分支。
用户不会看到你的状态机,也不会关心你用的是什么库。他们只会觉得:这个页面很顺,点下去之后,事情就发生了该发生的变化。
这大概就是状态管理最好的结局——它安静到没有人注意到它存在。