四层状态架构
React 应用的页面状态可按 URL identity、Query server projection、Streaming Runtime、UI state 分层;同时用 ownership/authority 轴区分客户端副本与 durable domain truth。
[!info] related notes
- 所属 MOC: React MOC, 前端工程化 MOC
- 身份层: URL 作为唯一身份源
- 数据层: TanStack Query 服务端状态
- 流式层: 前端 SSE 消费设计, TanStack Query 与 SSE 流式数据集成
- UI 层: useState
- 分类: 组件内服务端状态与客户端状态的区分
- 派生: 派生状态模式
- Durable recovery: Durable SSE Recovery with after_seq
四层状态架构
范围
适用于有路由、API 数据和流式交互的 React 应用,例如 AI 对话工作台、实时数据面板。它解决的是客户端页面如何分层组织状态,不是宣称这四层都是业务 truth owner。
两个维度必须分开:Layer 与 Ownership
这是最容易混淆的地方。
页面 Layer 回答
这份状态在客户端的哪个生命周期/机制中被管理?
URL
Query
Streaming Runtime
UI State
Ownership / Authority 回答
如果两份状态冲突,谁有资格决定事实?
例如:
Query cache revision = 42
Server revision = 43
Query 层确实“保存着 42”,但它没有 authority 把服务器改回 42。真正的 durable domain truth 仍由服务端拥有。
所以:
Storage location / state container ≠ source of truth.
四层定义
第一层:URL Identity
└─ 当前浏览/操作的是哪个资源?
第二层:Query Server Projection
└─ 客户端当前知道的服务端 read model/cache
第三层:Streaming Runtime Projection
└─ 当前正在发生的 run/turn 的临时事件投影
第四层:UI / Presentation State
└─ 纯前端交互状态
系统外侧还有一个不属于 React 四层的 authority:
Server Durable Domain / Database / Runtime Event Log
= authoritative durable facts
第一层:URL Identity
URL 负责回答:
当前资源是谁?
const routeConversationId = id && id !== 'new' ? id : null;
它适合表达可导航、可分享、刷新后仍应恢复的资源身份。
但 URL 是identity source,不是业务数据 truth:
URL says: /conversation/C1
Query/server says: C1 的内容是什么
第二层:Query Server Projection
TanStack Query 管理的是服务器数据在客户端的缓存/投影:
queryKey
queryFn
data
isPending / isFetching
staleTime
gcTime
invalidation
不要把它描述成“前端拥有服务端数据”。更准确是:
Server owns durable fact
Query owns synchronization/cache lifecycle of the client projection
典型原则:
- 不把 Query data 再长期复制到
useState/Zustand 独立维护。 - Mutation 后根据证据选择
setQueryData或invalidateQueries。 - 409 stale revision 时重新同步,因为本地 projection 已被证明过期。
第三层:Streaming Runtime Projection
流式 AI 交互需要即时显示:
text delta
tool lifecycle
citation
red flag
pending interaction
因此需要 ActiveTurn/streaming runtime 之类的 transient projection。
但:
Streaming Runtime
≠ durable truth
更完整的关系是:
Durable Runtime Event Log
├─→ Live SSE
└─→ after_seq replay
↓
same reducer
↓
ActiveTurn projection
网络连接可以断,ActiveTurn 可以丢失并重建;服务器上的 durable event/read model 才承担恢复 authority。
所以旧说法“流中以前端为准,流结束后以后端为准”不够准确。从始至终,业务 authority 都不因为 SSE 活跃而转移到前端;前端只是拥有当前 UI projection。
第四层:UI / Presentation State
典型:
const [mobileTab, setMobileTab] = useState<'chat' | 'info'>('chat');
const [isHistoryOpen, setIsHistoryOpen] = useState(false);
它通常:
- 由客户端自己创造和解释;
- 不需要服务器同步;
- 生命周期和页面/组件交互一致。
如果多个区域都需要同一个客户端 UI 状态,可以使用 Context/Zustand;global/shared 只代表消费范围,不自动代表业务 authority。
Local 与 Shared Client State 是 ownership 分类,不一定要变成“第五层”
例如:
Dialog open
→ local component presentation state
Sidebar collapsed
→ shared client presentation state
这两者都属于客户端 presentation 范畴,但 lifetime/scope 不同。
因此四层架构和下面这个 ownership 分类是互补的:
A Local component state
B URL state
C Shared client presentation state
D Server state/cache projection
E Durable domain truth
四层主要帮助设计页面数据流;A-E 更适合做 state-owner 判断。
一个统一判断方法
遇到任何 state,依次问:
1. 谁创造它?
2. 谁能修改它?
3. 谁需要消费它?
4. 应该活多久?
5. reload/navigation/reconnect 后是否应该恢复?
6. 如果两份副本冲突,谁决定真相?
第 6 个问题通常最能发现错误 ownership。
依赖路径
典型页面:
URL identity
↓ drives queryKey/enabled
Query server projection
↓ renders durable/read-model state
UI
同时:
Server durable event log
↓ live SSE / replay
Streaming Runtime projection
↓ renders current run
↓ terminal/reconcile
Query/read-model projection
这比“所有东西都塞一个全局 Store”更容易恢复、测试和定位竞态。
State 归属示例
| 值 | 客户端层/分类 | 真正 authority |
|---|---|---|
| Dialog 是否打开 | Local UI | Client |
| 未提交表单内容 | Local UI | Client,提交前 |
/conversations/:id 的 id | URL identity | URL/navigation contract |
| Sidebar collapsed | Shared client presentation | Client |
/api/me 返回的用户资料 | Server projection | Server |
| Query cache 中 HealthWorkspace | Query projection | Server |
| DB 中 HealthWorkspace | 不属于 React 四层 | Server durable domain |
| Query cache 的 BodyState revision | Query projection | Server |
| Runtime streaming text | Streaming projection | Durable event/read-model ultimately |
注意 /api/me 即使物理上放在 Zustand,也不改变它是 server-owned data projection 这一点。
常见失败模式
Server state 被复制成第二个全局 truth
TanStack Query data
→ copy to Zustand
→ independently mutate both
结果是 drift、stale write 和难以判断哪个版本正确。
Streaming Runtime 被当 durable history
刷新/崩溃后内存丢失,如果没有 Event Log/read model 就无法恢复。
URL identity 与 currentId state 双写
两套身份源更新不原子,删除/导航时容易重新请求旧资源。
把所有 Query refetch 都当整页 loading
isFetching 可能只是已有 cache 的后台刷新,不应该清空已有 UI。
面试表达
“我会先把页面状态的层和业务 ownership 分开。URL 负责当前资源 identity;TanStack Query 管服务器状态的客户端 cache 和同步生命周期;ActiveTurn/streaming runtime 管当前 run 的 transient projection;useState/Context/Zustand 管客户端 presentation state。真正的业务 durable truth 仍在服务端。判断边界时我最关注的是:如果两份副本冲突,谁有资格裁决。”
最短记忆
URL = 我现在看谁
Query = 我当前知道的服务器数据副本
Streaming= 当前正在发生什么的临时投影
UI State = 客户端交互状态
Server = durable business truth
容器不等于 owner;冲突时谁裁决,谁才是 authority。