状态同步先定源头
一个列表页上线后,问题不是接口慢,也不是样式错,而是筛选条件总在“自己变”。用户从详情页返回,搜索词还在,排序丢了;表单里改了草稿,切换 tab 又被接口数据覆盖;运营复制带 query 的链接给同事,对方打开后看到的状态和她不一样。研发查了一圈,发现页面里同一个值同时存在于 URL、React state、全局 store 和接口响应里。每一处都像合理,合在一起就变成互相抢话。
React 项目里的状态问题,最麻烦的往往不是“要不要用状态管理库”,而是没有先判断状态的源头。源头不清,后面所有同步都是补丁:一个 useEffect 监听 URL,一个 useEffect 同步 store,一个接口回来再 setState,一次交互触发三处更新。页面刚开始还能跑,需求一复杂,就会出现覆盖、闪烁、回退失效和难以复现的脏数据。
我更建议先把状态分成几类,再决定放在哪里。不是所有状态都应该进全局 store,也不是所有状态都该留在组件内部。状态位置是一个产品和工程共同决定的边界:谁需要读它,谁能修改它,它是否要被链接复现,它是否来自服务端,它能不能被丢弃。
先问这个值从哪里来,而不是放哪里
很多讨论会从工具开始:要不要 Redux,要不要 Zustand,要不要 Context,要不要 URL 参数。工具本身不是问题,问题是我们还没说清楚这个状态由谁负责。比如筛选条件,如果用户希望复制链接后别人看到同样列表,它就应该有 URL 语义;如果它只是一个临时展开面板的开关,就没必要进 URL;如果它影响多个页面的业务流程,也许需要全局状态;如果它只是输入框还没提交的草稿,放在表单内部反而更稳。
我通常先问五个问题。第一,这个状态是否来自服务端。第二,用户刷新后是否应该保留。第三,它是否需要通过 URL 分享或回放。第四,多个远距离组件是否都要读写它。第五,它是否只是当前组件的临时交互。答案不同,源头就不同。
状态来源地图:URL、服务端、表单、全局 Store 和派生值各自承担不同责任,先分清责任再谈同步。
服务端状态最典型,比如用户信息、订单列表、权限配置。它的源头是后端,前端只是缓存和展示。表单草稿的源头通常是用户当前输入,提交前不应该被接口返回轻易覆盖。URL 状态承担的是可分享和可回放,例如分页、筛选、排序。全局状态适合跨页面或跨模块共享的客户端状态,例如当前工作区、临时选择上下文。派生状态则不应该被单独存储,比如 selectedItems.length > 0 可以从已选列表算出来。
同步不是越快越好,关键是方向单一
状态同步最容易失控的地方,是双向同步。比如 URL 变了更新 state,state 变了又更新 URL;接口数据回来更新表单,表单变化又触发接口刷新;store 变化更新组件,组件初始化又写回 store。只要两边都觉得自己是源头,循环和覆盖就迟早出现。
比较稳的做法是让同步方向单一。URL 是源头时,组件从 URL 读值,用户确认筛选后再写 URL;服务端是源头时,接口响应更新缓存,组件从缓存读,用户提交后通过 mutation 修改服务端;表单是源头时,用户输入先留在表单,只有提交或保存草稿时才写到外层。这样每次变化都知道从哪里来,也知道下一步该去哪里。
useEffect 不是不能用,但它不应该变成状态同步的垃圾桶。很多页面里,一看见两个值不一致,就写一个 effect 把 A 赋给 B;再遇到另一个场景,又写一个 effect 把 B 赋给 A。短期能消掉 bug,长期会让页面像一台没人敢碰的机器。更好的方式是减少需要同步的状态,只保留真正的源头,把其他值做成计算结果。
- const page = Number(searchParams.get('page') ?? 1)
- const keyword = searchParams.get('keyword') ?? ''
- const query = useMemo(() => ({ page, keyword }), [page, keyword])
- const { data } = useProducts(query)
这段代码的重点不是写法多高级,而是 page 和 keyword 的源头是 URL。组件不再额外维护一份同名 state,数据请求也从 URL 派生出来。用户改筛选时,只要更新 URL,其他东西自然跟着走。少一份状态,就少一次同步。
还有一个边界常被忽略:同步动作应该发生在用户“确认”之后,还是用户“输入”之中。搜索框每输入一个字都写 URL,看起来实时,实际上会污染浏览历史,也可能触发过多请求;完全不写 URL,又会让用户无法分享当前结果。比较舒服的做法通常是草稿留在输入框里,按下搜索、失焦或防抖稳定后再提交到 URL。这样既保留了可回放能力,也不会让 URL 成为输入过程中的噪声。
服务端状态也类似。接口返回的数据不要直接变成所有字段的最终答案。比如编辑商品时,服务端返回的是“已保存版本”,用户正在表单里改的是“工作副本”。这两个概念混在一起,页面就会出现接口刷新覆盖用户输入的问题。更稳的做法是把接口数据当作初始化来源和对比基准,把用户草稿当作当前编辑源头,提交成功后再更新基准。
表单草稿要保护,接口数据要克制
表单是 React 状态问题里的高发区。编辑页打开后会拉取详情数据,然后把数据填进表单。问题出在后续:接口重新请求、父组件刷新、权限变化、默认值变化,都可能再次把表单覆盖掉。用户写了一半的内容突然没了,这种体验比普通 bug 更伤信任。
我的判断是,表单一旦进入编辑状态,用户输入就是当前源头。接口数据只能用于初始化,不能随便覆盖草稿。除非用户明确点击“重置”“恢复服务端版本”,否则后端新数据应该提示冲突,而不是悄悄替换。对于多人协作或长表单,这一点尤其重要。
可以把表单状态拆成三层:服务端原始数据、用户当前草稿、提交结果。原始数据用于对比,草稿用于编辑,提交结果用于反馈。不要把三层压成一个对象反复 set。压成一个对象看起来省事,但一旦需要判断哪些字段被用户改过、哪些字段来自服务端、哪些字段提交失败,就会开始补各种标记。
全局 store 要少装“路过的状态”
全局 store 很容易变成临时状态的仓库。因为它方便,任何组件都能拿,很多值就顺手放进去。过一段时间,store 里既有用户身份,又有弹窗开关,还有某个页面的临时筛选、某个表单的草稿、某次请求的 loading。它看起来像统一管理,实际是边界消失。
判断一个状态要不要进全局 store,可以看它是否跨页面长期有效,是否被多个远距离模块共享,是否有清晰的写入者。如果只是为了避免 props 传递,先考虑组件拆分或局部 context。全局 store 的成本不是写入那一刻,而是后面每个人都可能读写它,调试时也更难判断变化来源。
如果确实需要全局状态,也要给它规定写入入口。比如当前工作区只能由工作区切换动作修改,不能让任意组件在 mount 时顺手改;用户权限只能来自登录态和权限接口,不能由某个按钮点击临时补字段;全局弹窗队列只能由弹窗管理器写入,不能每个页面自己塞。写入入口越分散,状态越像共享变量,而不是共享模型。
代码评审时可以多问一句:这个 store 字段有没有唯一 owner。如果没有 owner,就很难判断谁应该修 bug。很多“偶现状态错乱”最后都不是 React 渲染问题,而是写入者太多,没有人真正负责这个值。
同步边界矩阵:能分享、能回放、能丢弃、要提交、要订阅、要缓存,这些属性决定状态应该放在哪里。
验收状态设计,不要只点正常路径
状态设计是否稳,不能只看页面第一次打开是否正常。至少要验收几个边界场景:刷新页面,返回上一页,复制链接给别人,接口慢返回,表单编辑中重新请求,切换 tab 后再回来,多组件同时修改同一值。很多同步问题只有在这些场景里才出现。
还有一个实用方法:给关键状态写一张来源表。列出状态名、源头、可写入者、是否可分享、是否可丢弃、刷新后是否保留、失败时如何恢复。这个表不需要很复杂,但它能让代码评审从“这里为什么不用 store”变成“这个值的源头到底是谁”。讨论一旦回到源头,很多争论会自然收敛。
来源表最好和验收用例放在一起。比如筛选条件的验收,不只点一次搜索,还要测试复制链接、浏览器后退、返回列表、清空筛选、接口失败后重试。表单草稿的验收,要覆盖接口慢返回、用户编辑中刷新局部数据、提交失败保留输入、提交成功后基准更新。全局状态的验收,要覆盖跨页面切换、退出登录、切换工作区和权限变更。
这些用例看起来比普通功能测试多一点,但它们能直接验证状态源头是否稳定。状态设计如果只在正常路径下成立,那它很容易在真实用户操作里失效。React 页面越复杂,越需要把“状态从哪里来”变成显式设计,而不是等 bug 出现后再顺着 effect 找原因。
如果团队已经有一堆历史页面,也不用一口气重构。可以先挑最容易出错的三类状态:URL 筛选、编辑表单、跨页面上下文。每次改需求时顺手补来源表和验收用例,慢慢把隐式约定显式化。状态治理不是大拆大改,更像把每个值的身份证补齐。
最后判断 React 状态是否清晰,不是看用了哪个库,而是看一个值变化时,团队能不能很快回答三个问题:是谁改的,为什么改,下一步会影响谁。如果这三个问题答不出来,说明状态已经不只是组件问题,而是信息边界问题。先把源头定清楚,再写同步逻辑,页面会少很多看不见的拉扯。
