← [ BACK_TO_NOTES ]

四层状态架构

React 应用的页面状态可按 URL identity、Query server projection、Streaming Runtime、UI state 分层;同时用 ownership/authority 轴区分客户端副本与 durable domain truth。

#synthesis #growing #frontend #react

[!info] related notes

四层状态架构

范围

适用于有路由、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 UIClient
未提交表单内容Local UIClient,提交前
/conversations/:id 的 idURL identityURL/navigation contract
Sidebar collapsedShared client presentationClient
/api/me 返回的用户资料Server projectionServer
Query cache 中 HealthWorkspaceQuery projectionServer
DB 中 HealthWorkspace不属于 React 四层Server durable domain
Query cache 的 BodyState revisionQuery projectionServer
Runtime streaming textStreaming projectionDurable 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。
创建于 2026/7/2 更新于 2026/9/16
Digital Biome
Access

键盘导航

j / k / ↑ / ↓
选择对象
Enter / o
打开选择
/ 或 Ctrl+K
全站搜索
i / f / 1–4
筛选当前列表
g → h / l / p / t / a
首页 / 笔记 / 项目 / 工具 / About
r → 1–9
打开所选对象的关联
u
返回上一页
Esc
关闭浮层 / 取消命令;输入框内清空文本