1 分钟

React 状态管理:在生成的应用中保持简单

让 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 状态

为你的下一个构建筹资
通过分享你的作品或推荐他人试用 Koder.ai 来获取积分。

表单问题通常来自把三件事混在一起:用户正在输入的内容、服务器已保存的内容,以及 UI 正显示的内容。

为了无趣的状态管理,把表单当作客户端状态直到提交。服务器数据是最后保存的版本。表单是草稿。不要就地编辑服务器对象。把值复制到草稿状态,让用户自由修改,然后在成功时提交并重获取或更新缓存。

提前决定当用户离开时哪些东西应该保留。这一个选择能避免很多意外 bug。例如,内联编辑模式和打开的下拉通常应该重置,而长流程的草稿或未发送的消息草稿可能需要持久化。只有在用户明确期待时才跨页面持久化(比如结账表单)。

把校验规则放在一个地方。如果你把规则分散到输入、提交处理器和 helper,会得到不匹配的错误。优先使用一个 schema(或一个 validate() 函数),由 UI 决定何时显示错误(在变更、失焦或提交时)。

示例:你用 Koder.ai 生成一个编辑个人资料界面。把已保存的配置作为服务器状态加载。为表单字段创建草稿状态。通过比较草稿与保存版来显示“未保存的更改”。如果用户取消,丢弃草稿并显示服务器版本。如果保存,提交草稿,然后用服务器响应替换保存版本。

逐步简化混乱状态配置

随着生成的 React 应用增长,常见情况是相同数据出现在三个地方:组件状态、全局 store 和缓存。解决办法通常不是引入新库,而是为每条数据选一个归属地。

一个适用于大多数应用的清理流程:

  1. 清点你的状态。列出你拥有的(列表、选中项、过滤器、模态、草稿),并标注为服务器或客户端。
  2. 删除重复项。如果可以从 users + filter 计算出 filteredUsers,就删除 filteredUsers。优先 selectedUserId 而不是重复的 selectedUser 对象。
  3. 把抓取放在一层。使用一种服务器状态方案,这样缓存、重获取和失效只有一套规则。
  4. 仅在有价值时添加小型客户端 store(跨页面 UI 需求如向导草稿)。
  5. 固化命名规则,防止混乱卷土重来。

示例:一个 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 状态码。
  • 每个新屏幕都会增加像 needsRefreshdidInitisSaving 这样的共享标志而没有清理。
  • 你主要在写 effect 来把一个状态镜像到另一个。
  • 修复 bug 需要在多个层次更新同一个字段。

示例:你用 Koder.ai 生成了一个仪表盘并添加了编辑个人资料模态。如果个人资料数据既存在于查询缓存、又复制到全局 store,并且又重复在本地表单状态里,你现在有三个真实来源。第一次加入后台重获取或乐观更新时,不一致就会显现。

当你看到这些信号时,无趣的做法是为每条数据选一个单一所有者并删除镜像。

常见陷阱以及如何避免它们

“以防万一”地存东西是让状态变痛苦的快速方法,尤其是在生成的应用中。

把 API 响应复制到全局 store 是常见陷阱。如果数据来自服务器(列表、详情、用户资料),默认不要把它复制到客户端 store。为服务器数据选一个归属地(通常是查询缓存)。客户端 store 用来存服务器不知道的 UI 专用值。

存派生值是另一个陷阱。计数、过滤列表、总计、canSubmitisEmpty 通常应从输入计算。如果性能确实成为问题,先测量并用 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 变化时重获取”)。
  • 不断累积的共享标志如 needsRefreshdidInitisSaving
  • 你无法在一句话内解释“这个值来自哪里?”

通常修复方法不是换库,而是删除镜像并为每个值选一个单一的归属地。

在使用像 Koder.ai 的生成器时,如何让状态保持“无趣”?

生成的屏幕很容易快速产生混杂模式。一个简单的防护是标准化所有权:

  • 服务器状态: 总是通过同一个获取/缓存层。
  • 客户端 UI 状态: 默认本地,需要时再放到 store。
  • 派生值: 计算而不是存储。

如果你在使用 Koder.ai,使用 Planning Mode 在生成新屏幕前确定所有权,并在尝试状态改动时依赖快照/回滚,这样容易恢复。

Related posts