使用 Codex 修改 React、Vue 或其他前端项目时,状态管理是一个非常容易越改越复杂的地方。

刚开始项目可能只有:

组件内部 state

随着功能增加,又逐渐出现:

Pinia / Redux / Zustand
LocalStorage
URL Query
接口缓存
组件 Props
SessionStorage

然后就会出现一个很典型的问题:

同一个业务状态,在项目里出现了好几份。

比如当前用户选择的城市,同时存在于:

URL:
?city=shanghai

Store:
selectedCity = "beijing"

LocalStorage:
city = "guangzhou"

组件内部:
city = "shenzhen"

此时页面到底应该相信谁?

如果没有明确规则,Codex 很容易为了修复当前页面,再增加一份同步逻辑,最后让状态越来越难维护。


一、什么是“状态分叉”?

所谓状态分叉,就是同一个业务事实,被多个地方分别保存。

例如购物车数量:

Header组件:
cartCount = 3

Redux:
cartCount = 4

LocalStorage:
cartCount = 2

服务端:
cartCount = 5

页面不同位置读取不同来源,就可能出现:

顶部显示3件
购物车页面显示4件
刷新后变成2件
重新请求接口后又变成5件

代码每一处都可能“看起来合理”,但系统已经没有统一答案。

状态管理最重要的原则不是:

数据存在哪里最方便?

而是:

这份数据的唯一可信来源是谁?


二、先给每种状态分类

开始修改前,可以先把项目状态分成几类。

1. 服务端状态

例如:

用户信息
订单列表
商品库存
支付状态
权限数据

这类数据真正的来源通常是后端。

前端 Store 或 Query Cache 只是缓存。


2. URL状态

例如:

当前页码
搜索关键词
筛选条件
排序方式
Tab

如果用户刷新页面、复制链接后仍然应该保留,那么 URL 往往更适合作为事实源。

例如:

/products?page=3&keyword=codex&sort=price

此时没有必要再额外维护一份:

const page = ref(3);
const keyword = ref("codex");

然后双向同步。


3. 全局客户端状态

例如:

当前主题
侧边栏是否折叠
当前用户UI偏好
跨页面临时选择

适合放在 Pinia、Redux、Zustand 等 Store 中。


4. 局部UI状态

例如:

弹窗是否打开
输入框是否聚焦
当前菜单是否展开
临时表单值

这些通常只属于当前组件。

没必要全部进入全局 Store。


三、不要把所有状态都塞进Store

Codex 有时会为了“统一管理”,把大量状态迁移到全局 Store:

const store = {
  searchKeyword,
  modalVisible,
  currentTab,
  formData,
  hoverIndex,
  tableLoading,
  page,
  selectedRow,
  ...
};

这样虽然都能访问,但会产生新的问题:

  • 页面卸载后状态还在;

  • A页面修改影响B页面;

  • 测试需要初始化大量无关状态;

  • 一个小组件也依赖全局Store;

  • 状态生命周期变得模糊。

一个简单判断标准是:

如果这个状态只被一个组件或一个页面使用,它通常不应该直接进入全局Store。


四、避免Store与LocalStorage双向同步失控

常见代码:

const theme = ref(
  localStorage.getItem("theme") || "light"
);

watch(theme, value => {
  localStorage.setItem("theme", value);
});

这类简单场景没有问题。

但复杂项目中可能出现:

应用启动
↓
LocalStorage写入Store
↓
Store触发watch
↓
又写回LocalStorage
↓
其他模块监听Storage事件
↓
再次更新Store

如果没有清晰方向,就容易形成循环同步。

更推荐定义:

LocalStorage
= 持久化层

Store
= 运行时状态

启动时:

LocalStorage
→ Store

运行中:

Store变化
→ 持久化到LocalStorage

而不是多个模块同时互相同步。


五、URL与Store不要同时保存同一个筛选条件

假设列表页使用:

?status=active&page=2

又同时在Store中保存:

{
  status: "active",
  page: 2
}

此时用户点击浏览器返回键:

URL 可能变成:

?page=1

但Store仍然:

page = 2

页面到底显示哪一个?

如果筛选条件应该出现在链接中,建议明确:

URL = 唯一事实源

组件读取:

const page = Number(route.query.page ?? 1);

而不是再额外维护一份可独立修改的 page


六、服务端数据不要手动复制进多个Store

例如使用 TanStack Query、React Query 或其他数据请求缓存时:

Query Cache
已经保存用户信息

但项目又做:

请求用户
→ 写Query Cache
→ 再写Redux
→ 再写LocalStorage

结果用户数据有三份。

以后修改头像时,就需要:

更新Query Cache
更新Redux
更新LocalStorage

任何一步漏掉,页面就会出现旧数据。

如果服务端数据已经由数据请求层管理,最好让它继续作为服务端状态缓存。

全局Store只保存真正的客户端状态。


七、派生状态不要重复保存

这是非常常见的问题。

例如:

const firstName = "Tom";
const lastName = "Lee";

又额外保存:

const fullName = "Tom Lee";

如果 firstName 改成 Jerry,但忘记同步 fullName,就出现状态分叉。

更合理的是:

const fullName =
  `${firstName} ${lastName}`;

或者使用 computed / selector。

类似的还有:

订单列表
+
订单数量

如果订单数量可以通过:

orders.length

得到,就不要再保存一个独立 orderCount

能够计算出来的状态,尽量不要重复存储。


八、状态写入点越少越好

排查状态问题时,可以让 Codex 先找:

谁读取这个状态?
谁修改这个状态?
谁持久化这个状态?

例如:

selectedProjectId

发现有:

Header修改
Sidebar修改
Router修改
Store初始化修改
LocalStorage恢复修改

五个写入点。

此时问题不是“哪一行写错”,而是写入入口太多。

可以收敛成:

function selectProject(projectId: string) {
  store.projectId = projectId;
}

所有模块只能调用:

selectProject()

而不是直接:

store.projectId = ...

这样更容易追踪状态变化。


九、给Codex先做一张“状态地图”

遇到状态混乱时,不要直接让 Codex 重构。

可以先问:

请先不要修改代码。

分析 selectedProjectId 的状态流:

1. 初始值来自哪里;
2. 哪些文件读取;
3. 哪些文件写入;
4. 是否存入LocalStorage;
5. 是否来自URL;
6. 是否存在重复状态;
7. 推荐哪个位置作为唯一事实源。

理想输出:

事实源:
URL query.projectId

读取:
ProjectPage
Sidebar
Header

重复状态:
projectStore.currentProjectId

持久化:
LocalStorage中的projectId

建议:
保留URL作为事实源,
删除Store中的重复字段,
LocalStorage只用于生成默认URL。

这比直接“优化状态管理”安全得多。


十、注意初始化顺序

状态管理问题经常发生在应用启动阶段。

例如:

Store默认值:A

LocalStorage恢复:B

URL参数:C

接口返回:D

如果四个来源都会更新状态,启动时可能连续变化:

A
→ B
→ C
→ D

页面就会闪烁甚至发送多次请求。

应该明确优先级。

例如:

URL
>
LocalStorage
>
默认值

服务端数据则由独立查询流程加载。

启动逻辑应该可以清楚解释:

先读URL
没有URL则读取持久化偏好
都不存在才使用默认值

而不是所有来源同时写入。


十一、不要为了“修复刷新丢失”把所有状态持久化

Codex 遇到:

刷新以后状态没了。

很容易建议:

localStorage.setItem(...)

但并不是所有状态都应该跨刷新保存。

例如:

弹窗打开状态
表单提交中
Loading
当前Hover元素
临时错误提示

刷新后消失完全正常。

真正应该持久化的是:

用户明确选择的长期偏好

而不是:

所有运行时状态

十二、复杂状态建议使用状态机

例如上传任务存在:

idle
selecting
uploading
processing
success
error
cancelled

如果使用很多布尔值:

isUploading
isProcessing
isError
isSuccess

就可能出现:

isUploading = true
isSuccess = true

这种互相冲突的状态。

可以直接使用:

type UploadStatus =
  | "idle"
  | "uploading"
  | "processing"
  | "success"
  | "error";

这样一次只能处于一个明确状态。

复杂流程中,状态机往往比“再增加一个boolean”更稳定。


十三、状态重构必须增加回归测试

状态问题特别适合测试用户流程。

例如:

打开列表页
→ 修改筛选条件
→ URL更新
→ 刷新页面
→ 筛选条件保持
→ 浏览器返回
→ 状态恢复

还可以测试:

Store重置
LocalStorage为空
URL参数非法
服务端请求失败
多Tab切换

不要只测试:

store.setValue()

然后判断:

value === expected

真正需要验证的是状态在完整生命周期中是否一致。


十四、把单一事实源写进AGENTS.md

可以加入:

# 状态管理规则

- 同一业务状态只允许一个事实源
- URL状态不要在Store中重复保存
- 服务端数据优先由Query Cache管理
- 可计算的派生状态不要重复存储
- 局部UI状态不要无理由放入全局Store
- LocalStorage只负责持久化,不作为多个模块的运行时状态源
- 状态写入必须尽量收敛到统一入口
- 修改状态结构前先输出状态流向
- 修复状态问题后必须增加刷新、返回和初始化测试

这样 Codex 在重构状态管理时,就不会简单通过“再增加一个同步变量”解决当前问题。


十五、Plus还是Pro?

如果主要处理:

单页面状态
小型Pinia / Redux Store
普通表单
局部状态Bug

Plus通常已经可以覆盖大部分 Codex 开发任务。

如果项目包含:

大型前端仓库
多个Store
URL与缓存联动
复杂数据请求
跨页面状态
多轮重构与测试

则可以根据实际开发强度评估 Pro。

不过无论使用哪个方案,状态管理最重要的原则都不会改变:

更多上下文不能解决多个事实源互相打架的问题。

总结

Codex 改状态管理越改越乱,通常不是 Store 工具本身有问题,而是同一份数据被复制到了太多位置。

通过区分服务端状态、URL状态、全局状态和局部UI状态,再为每份数据指定唯一事实源,可以大幅减少同步代码和状态分叉。

真正清晰的状态管理应该能够快速回答:

这份数据最终应该相信谁?

只要这个问题没有唯一答案,后续代码就一定会越来越难维护。

CSDN文章描述

本文介绍 Codex 修改前端状态管理时常见的状态分叉问题,通过单一事实源、URL状态、Store、LocalStorage、Query Cache 和状态机设计,减少多份状态不同步与页面数据显示异常。

更多推荐