React 状态管理:在生成的应用中保持简单
让 React 状态管理更简单:分清服务器状态与客户端状态,遵循几条规则,并尽早发现复杂度上升的信号。

现实 React 应用里的状态问题在哪儿
状态是指在应用运行时会变化的任何数据。它包括你看到的东西(模态是否打开)、你正在编辑的内容(表单草稿),以及你从后端获取的数据(项目列表)。问题在于,这些都被称为状态,但它们的行为差异很大。
大多数混乱的应用都是以相同的方式崩溃的:太多不同类型的状态混在同一个地方。一个组件可能同时保存服务器数据、UI 标志、表单草稿和派生值,然后试图用 effect 把它们对齐。很快你就无法回答像“这个值来自哪里?”或“是什么更新了它?”这样的问题,而不得不在好几个文件中寻找答案。
生成的 React 应用更容易走向这种情况,因为接受第一个能跑的版本往往很容易。你新增一个屏幕,复制一个模式,用另一个 useEffect 修补一个 bug,现在你有了两个真实来源。如果生成器或团队中途改变方向(这里用本地状态,那里用全局 store),代码库就会堆积各种模式,而不是在一个模式上持续改进。
目标是“无趣”:更少的状态类型,更少的查看地点。当服务器数据只有一个明显归属,UI 专用状态也只有一个明显归属时,bug 会变小,变更也不会让人感到冒险。
“保持无趣”意味着遵循几条规则:
- 除非有明确理由,否则不要把服务器数据镜像到客户端状态。
- 不要仅仅因为将来可能有用就把本地 UI 状态提升到全局 store。
- 除非计算成本真的很高,否则不要让派生值成为独立状态。
一个具体例子:如果用户列表来自后端,就把它当作服务器状态,在使用它的地方去获取。如果 selectedUserId 只是用于驱动一个详情面板,就把它作为靠近该面板的小范围 UI 状态。把这两者混在一起就是复杂性的起点。
用通俗语言讲服务器状态与客户端状态
大多数 React 状态问题源于一个混淆:把服务器数据当成 UI 状态。早早把它们分开,即便应用增长,状态管理也会保持冷静。
服务器状态属于后端:用户、订单、任务、权限、价格、功能开关。它可以在你的应用不做任何事时发生变化(另一个标签页更新、管理员编辑、后台作业运行、数据过期)。因为它是共享且会变的,所以需要抓取、缓存、重新抓取和错误处理。
客户端状态是浏览器当前关心的东西:哪个模态打开、哪个标签被选中、过滤切换、排序方式、折叠的侧边栏、表单草稿。如果你关闭标签页,丢失它通常没问题。
一个快速测试是:"我能刷新页面并从服务器重建这个吗?"
- 如果能,它很可能是服务器状态。
- 如果不能,它很可能是客户端状态。
还有派生状态,它可以避免你创建额外的状态。派生状态是可以从其他值计算出的结果,所以你不去存它。过滤后的列表、总计、isFormValid、“显示空状态”通常属于派生。
示例:你抓取一个项目列表(服务器状态)。所选的过滤器和“新建项目”对话框的打开标志是客户端状态。过滤后可见的列表是派生状态。如果你单独存储可见列表,它会与源数据不同步,你就会去追踪“为什么会变陈旧?”的 bug。
这种划分对像 Koder.ai 这样的工具快速生成屏幕很有帮助:把后端数据放到一个抓取层,把 UI 选择放在组件附近,避免存储计算值。
一组能预防大多数问题的无趣规则
当一条数据有两个所有者时,状态就会变得痛苦。保持简单的最快方式是决定谁拥有什么并坚持下去。
- 把 API 数据当成你去抓取和缓存的东西,而不是手动维护在组件状态中。
- 不要“以防万一”把抓取到的数据克隆到本地状态。复制会造成漂移。
- 把客户端状态保存在使用它的附近。只有当树的不同部分真正需要时才向上移动。
- 存 ID 和小标志,而不是整个对象。渲染时从缓存中重新派生对象。
示例:你抓取一个用户列表并在选中时显示详情。常见错误是把完整的选中用户对象存到状态里。应该改为存 selectedUserId。把列表保留在服务器缓存中。详情视图按 ID 查找用户,这样重新抓取时 UI 能自动更新而不需要额外同步代码。
在生成的 React 应用里,也容易接受“看起来有用”的生成状态去重复服务器数据。当你看到代码流程是 fetch -> setState -> edit -> refetch,停一下想想。这通常意味着你在浏览器里构建了第二个数据库。
如何在不过度思考的情况下处理服务器状态
服务器状态是任何存在于后端的东西:列表、详情页、搜索结果、权限、计数。无趣的做法是选一个工具来处理它并坚持使用。对许多 React 应用来说,TanStack Query 就足够了。
目标很直接:组件请求数据、显示加载和错误状态,而不关心底层发生了多少次 fetch。这在生成的应用里尤其重要,因为小的不一致会随着新屏幕增加而快速放大。
把查询键当作命名体系而不是事后的考虑。保持一致性:稳定的数组键,只包含会改变结果的输入(过滤器、页码、排序),宁可有几个可预测的形状也不要大量一次性的键。很多团队也会把键的构建放在小的 helper 中,这样每个屏幕遵循相同规则。
对于写操作,使用 mutation 并在成功时明确处理。一个 mutation 应该回答两个问题:什么被改变了,接下来 UI 应该怎么做?
示例:你创建了一个新任务。成功后,要么让任务列表的查询失效(让它重新加载一次),要么做有针对性的缓存更新(把新任务插入到缓存列表)。每个功能选一种方法并保持一致。
如果你想在多个地方添加 refetch 调用“以防万一”,选一个无趣的统一动作替代:
- 失效那个变陈旧的精确查询键。
- 更新变更对应的缓存。
- 导航到已经抓取正确查询的屏幕。
保持小巧的客户端状态模式
客户端状态是浏览器拥有的东西:侧边栏开关、选中行、过滤文本、保存前的草稿。把它保存在使用它的附近,通常就能保持可控。
从小处开始:在最近的组件里用 useState。当你生成屏幕(比如用 Koder.ai)时,很容易把一切都推到全局 store“以防万一”。结果就是你会有一个没人懂的 store。
一个简单的提升规则
只有当你能明确说出共享问题时才向上移动状态。
- 默认把 UI 专用状态保持本地。
- 当兄弟组件需要共享时,把它提升到最近的共同父组件。
- 当多个路由或远端组件同时需要相同值时,才使用一个小型共享 store。
示例:一个有详情面板的表格可以把 selectedRowId 保存在表格组件中。如果页面的另一个工具栏也需要它,就把它提升到页面组件。如果一个独立路由(比如批量编辑)也需要它,那时小型 store 才有意义。
保持可读的 store 形状
如果你使用 store(Zustand 或类似),让它专注于一个职责。存“是什么”(选中 ID、过滤器),而不是“结果”(可排序的列表)这类你可以派生的数据。
当一个 store 开始膨胀时问问自己:这还是一个功能吗?如果答案是“有点”,现在就拆分它,别等下一个功能把它变成不敢动的状态球。
表单、草稿和临时 UI 状态
表单问题通常来自把三件事混在一起:用户正在输入的内容、服务器已保存的内容,以及 UI 正显示的内容。
为了无趣的状态管理,把表单当作客户端状态直到提交。服务器数据是最后保存的版本。表单是草稿。不要就地编辑服务器对象。把值复制到草稿状态,让用户自由修改,然后在成功时提交并重获取或更新缓存。
提前决定当用户离开时哪些东西应该保留。这一个选择能避免很多意外 bug。例如,内联编辑模式和打开的下拉通常应该重置,而长流程的草稿或未发送的消息草稿可能需要持久化。只有在用户明确期待时才跨页面持久化(比如结账表单)。
把校验规则放在一个地方。如果你把规则分散到输入、提交处理器和 helper,会得到不匹配的错误。优先使用一个 schema(或一个 validate() 函数),由 UI 决定何时显示错误(在变更、失焦或提交时)。
示例:你用 Koder.ai 生成一个编辑个人资料界面。把已保存的配置作为服务器状态加载。为表单字段创建草稿状态。通过比较草稿与保存版来显示“未保存的更改”。如果用户取消,丢弃草稿并显示服务器版本。如果保存,提交草稿,然后用服务器响应替换保存版本。
逐步简化混乱状态配置
随着生成的 React 应用增长,常见情况是相同数据出现在三个地方:组件状态、全局 store 和缓存。解决办法通常不是引入新库,而是为每条数据选一个归属地。
一个适用于大多数应用的清理流程:
- 清点你的状态。列出你拥有的(列表、选中项、过滤器、模态、草稿),并标注为服务器或客户端。
- 删除重复项。如果可以从
users+filter计算出filteredUsers,就删除filteredUsers。优先selectedUserId而不是重复的selectedUser对象。 - 把抓取放在一层。使用一种服务器状态方案,这样缓存、重获取和失效只有一套规则。
- 仅在有价值时添加小型客户端 store(跨页面 UI 需求如向导草稿)。
- 固化命名规则,防止混乱卷土重来。
示例:一个 Koder.ai 生成的 CRUD 应用通常从 useEffect 抓取并把同一列表复制到全局 store 开始。把服务器状态集中后,列表来自一个查询,“刷新”变成了使查询失效而不是手动同步。
命名方面,保持一致且无趣:
- Queries:
users.list,users.detail(id) - Client state:
ui.isCreateModalOpen,filters.userSearch - Actions:
openCreateModal(),setUserSearch(value) - Server writes:
users.create,users.update,users.delete
目标是每样东西只有一个真实来源,并在服务器状态与客户端状态之间保持清晰边界。
状态复杂性将要爆发的早期信号
状态问题从小开始,然后某天你改一个字段,UI 的三个部分就不同意“真实”值是什么。
最明显的警告信号是数据重复:同一个用户或购物车同时存在于组件、全局 store 和请求缓存中。每一份副本在不同时间更新,你不得不写更多代码去保持它们一致。
另一个信号是同步代码:来回推状态的 effect。像“当查询数据变化时,更新 store”和“当 store 变化时,重获取”的模式可能工作,但一旦触发边缘情况就会出现陈旧值或循环。
一些快速的红旗:
- 你无法在一句话内解释“这个值来自哪里”和“谁拥有它”。
- 你的 store 或 reducer 导入了 API 客户端或知道 HTTP 状态码。
- 每个新屏幕都会增加像
needsRefresh、didInit、isSaving这样的共享标志而没有清理。 - 你主要在写 effect 来把一个状态镜像到另一个。
- 修复 bug 需要在多个层次更新同一个字段。
示例:你用 Koder.ai 生成了一个仪表盘并添加了编辑个人资料模态。如果个人资料数据既存在于查询缓存、又复制到全局 store,并且又重复在本地表单状态里,你现在有三个真实来源。第一次加入后台重获取或乐观更新时,不一致就会显现。
当你看到这些信号时,无趣的做法是为每条数据选一个单一所有者并删除镜像。
常见陷阱以及如何避免它们
“以防万一”地存东西是让状态变痛苦的快速方法,尤其是在生成的应用中。
把 API 响应复制到全局 store 是常见陷阱。如果数据来自服务器(列表、详情、用户资料),默认不要把它复制到客户端 store。为服务器数据选一个归属地(通常是查询缓存)。客户端 store 用来存服务器不知道的 UI 专用值。
存派生值是另一个陷阱。计数、过滤列表、总计、canSubmit 和 isEmpty 通常应从输入计算。如果性能确实成为问题,先测量并用 memo 化,再考虑存储。
把所有东西都放到一个巨大的 mega-store(认证、模态、提示、过滤、草稿、入职标志)会把它变成一个垃圾场。按功能边界拆分。如果状态只被一个屏幕使用,就把它保存在本地。
Context 适合稳定的值(主题、当前用户 id、语言)。对于快速变化的值,Context 会导致广泛的重新渲染。用 Context 做线路连接,用组件状态或小型 store 管理频繁变化的 UI 值。
最后,避免命名不一致。近似重复的查询键和 store 字段会造成细微的重复。选一个简单的标准并遵守它。
在添加更多状态前的快速检查表
当你有冲动再加一个状态变量时,做一个快速的所有权检查。
首先,你能指出一个发生服务器抓取和缓存的地方吗(一个查询工具,一组查询键)?如果相同数据在多个组件中被抓取并且还被复制到 store,你已经在付出利息了。
第二,这个值只是某个屏幕内部需要的吗(比如“过滤面板是否打开”)?如果是,就不应该是全局的。
第三,你能否只存一个 ID 而不是复制一个对象?存 selectedUserId 并从缓存或列表读取用户。
第四,它是派生的吗?如果可以从现有状态计算出它,就不要存它。
最后做一个一分钟追踪测试。如果同事在不到一分钟内不能回答“这个值来自哪里?”(prop、本地状态、服务器缓存、URL、store),那就在添加更多状态前修复所有权。
示例:一个随着增长仍然保持无趣的 CRUD 应用
想象一个生成的管理后台(例如由 Koder.ai 的提示生成)有三个屏幕:客户列表、客户详情页和编辑表单。
当状态有明确归属时,一切保持平静:
- 服务器状态:客户列表、客户记录、保存/删除 请求。
- 客户端状态:列表过滤器、选中行、编辑草稿。
列表和详情页从查询缓存读取服务器状态。保存时,不把客户再次存入全局 store。发送 mutation,然后让缓存刷新或更新。
对于编辑页面,把表单草稿保留为本地。用抓取到的客户初始化,但一旦用户开始输入就把它当作独立草稿。这样详情视图可以安全刷新而不会覆盖尚未完成的更改。
一个无趣的乐观更新
乐观 UI 常让团队复制一切。多数情况下不需要。
当用户点击保存,只更新缓存中的客户记录和对应列表项,需要时在请求失败时回滚。保存期间把草稿保留在表单里。若失败,显示错误并保留草稿以便重试。
当另一个功能也想要相同状态时
比如你添加了批量编辑,也需要选中行。在创建新 store 之前,先问:这个状态需要在导航和刷新间保留吗?
- 如果需要,把它放到 URL(选中 ID,过滤条件)。
- 如果不需要,就保留在页面组件里。
- 如果多个远端组件确实需要同时访问它(工具栏 + 表格 + 页脚),为该客户端状态引入一个小型共享 store 即可。
下一步:在生成的应用中保持规则一致
生成的屏幕可能很快成倍增加,这很好,直到每个新屏幕带来自己的状态决定。
在仓库里写下一段简短的团队说明:什么算作服务器状态、什么算作客户端状态、每种状态由什么工具负责。保持简短,这样人们才会真的遵守。
加入一个小的 PR 习惯:为每个新状态打上 server 或 client 标签。若是服务器状态,问“在哪里加载、如何缓存、什么会使它失效?”若是客户端状态,问“谁拥有它、什么时候重置?”
如果你在使用 Koder.ai,Planning Mode 能帮助你在生成新屏幕前就达成状态边界一致。当你对某次状态修改遇到问题时,快照和回滚能让你安全试验。
选择一个特性(比如编辑个人资料),把规则端到端地应用,让它成为大家复制的示例。
常见问题
解开混乱的 React 状态最简单的方法是什么?
先把每一项状态标注为:服务器(server)、客户端(UI)或派生。
- 服务器(Server): 诸如用户、任务、权限等从后端获取的数据。
- 客户端(Client): 只在当前 UI 关心的状态,比如“模态是否打开”、选中的标签、过滤文本。
- 派生(Derived): 可以从其它值计算得到的值(过滤后的列表、总计、
isValid)。
标注完成后,确保每项状态只有一个明显的归属地(查询缓存、本地组件状态、URL 或一个小型 store)。
我怎么判断某个东西是服务器状态还是客户端状态?
用这个快速测试:“刷新页面后我能否从服务器重建它?”
- 如果 能,把它当作 服务器状态(fetch/缓存/重新获取)。
- 如果 不能,把它当作 客户端状态(本地 UI 状态,可能是一个 store)。
例如:项目列表是服务器状态;选中的行 ID 是客户端状态。
为什么把 API 数据复制到本地或全局状态是大问题?
因为它会造成两个真实来源(two sources of truth)。
如果你把 users 拉下来又复制到 useState 或全局 store,就需要在以下情况保持同步:
- 后台重新获取
- 来自其他标签页/用户的更新
- 变更后的局部更新
默认规则:从一个地方读取服务器数据(通常是查询缓存),只为 UI 专用项或草稿创建本地状态。
什么时候可以存储派生值而不是计算它们?
仅当你确实不能廉价地计算时才存储派生值。
通常从现有输入计算就够了:
visibleUsers = users.filter(...)total = items.reduce(...)canSubmit = isValid && !isSaving
如果性能成为真实问题(并经过测量),优先使用 useMemo 或更好的数据结构,而不是提前把结果存起来导致可能变陈旧的状态。
在 React 中以“无趣”的方式处理服务器状态是什么样的?
默认做法:使用一个服务器状态工具(常见是 TanStack Query),让组件只需“请求数据”,并处理加载/错误状态。
实用要点:
- 使用稳定且一致的查询键(只包含会改变结果的输入)。
- 写操作用mutations,并为每个特性选一个策略:
- 使对应的查询失效以触发重载,或者
- 对缓存做有针对性的更新。
避免在各处散布 refetch() 调用来“以防万一”。
什么时候应该把 UI 状态放进全局 store?
默认保持为局部,只有当你能明确说明共享需求时才向上提升。
提升规则:
- 默认使用组件本地状态。
- 当兄弟组件需要共享时,把状态提升到最近的共同父组件。
- 只有当多个远端组件或路由同时需要同一个值时,才使用小型共享 store。
这样可以避免全局 store 变成各种 UI 标志的垃圾场。
我应该存选中的对象还是只存它的 ID?
存 ID 和小的标志,不要存整个服务器对象。
示例:
- ✅
selectedUserId - ❌
selectedUser(复制出的对象)
渲染详情时从缓存或列表中按 ID 查找用户。这样后台重获取或更新可以正确反映到 UI,而无需额外的同步代码。
管理表单和草稿最干净的方法是什么?
把表单当作**草稿(客户端状态)**直到提交。
实用模式:
- 把保存的记录作为服务器状态获取。
- 从它初始化本地草稿。
- 让用户自由编辑草稿。
- 提交时发送草稿,然后刷新或更新服务器缓存。
- 取消时丢弃草稿并显示服务器版本。
这样可以避免“就地”编辑服务器数据并与重获取冲突。
有哪些早期迹象表明状态复杂度即将爆炸?
常见的预警信号:
- 同一数据同时存在于组件、本地/全局 store 和查询缓存中。
- 使用 effect 在状态之间来回镜像(“当查询变化时设置 store”,“当 store 变化时重获取”)。
- 不断累积的共享标志如
needsRefresh、didInit、isSaving。 - 你无法在一句话内解释“这个值来自哪里?”
通常修复方法不是换库,而是删除镜像并为每个值选一个单一的归属地。
在使用像 Koder.ai 的生成器时,如何让状态保持“无趣”?
生成的屏幕很容易快速产生混杂模式。一个简单的防护是标准化所有权:
- 服务器状态: 总是通过同一个获取/缓存层。
- 客户端 UI 状态: 默认本地,需要时再放到 store。
- 派生值: 计算而不是存储。
如果你在使用 Koder.ai,使用 Planning Mode 在生成新屏幕前确定所有权,并在尝试状态改动时依赖快照/回滚,这样容易恢复。