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

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