四层状态架构
React 应用的状态分四层:URL 负责身份、Query 负责服务端数据、Streaming Runtime 负责流式临时数据、useState 负责 UI 状态。各层职责清晰,不互相替代。
#type / synthesis
#status / growing
#tech / dev / frontend
#resource / react
[!info] related notes
- 所属 MOC: React MOC, 前端工程化 MOC
- 身份层: URL 作为唯一身份源
- 数据层: TanStack Query 服务端状态
- 流式层: 前端 SSE 消费设计, TanStack Query 与 SSE 流式数据集成
- UI 层: useState
- 分类: 组件内服务端状态与客户端状态的区分
- 派生: 派生状态模式
四层状态架构
范围
适用于有路由、有 API 数据、有流式交互(SSE/WebSocket)的 React 应用。典型场景:AI 对话工作台、实时数据面板、协作编辑器。
为什么要放在一起理解
大多数 React 页面的状态管理问题,不是工具不够,而是分层不清。一个 AI 对话页面可能同时有:
- 路由参数(当前看的是哪个会话)
- API 返回的数据(会话列表、消息、诊断结果)
- SSE 流式数据(正在生成的 AI 回复)
- 纯 UI 状态(当前 tab、侧边栏开关)
如果全部用 useState 混在一起管理,就会出现”URL 是旧 ID 但 currentConversation 被清空,于是 effect 又去请求旧 ID”这类竞态。
四层定义
第一层:URL 身份层
└─ 当前资源是谁?routeConversationId
第二层:Query 服务端数据层
├─ ['consultation', 'conversations'] → 会话列表
├─ ['consultation', 'conversation', id] → 会话详情 + 消息
└─ ['consultation', 'session', id] → 诊断领域数据
第三层:Streaming Runtime 层
└─ AssistantChatPanel 内部
├─ 当前流式 token
├─ 临时消息(未持久化)
└─ SSE 事件分发
第四层:UI 状态层
├─ mobileTab
├─ isMobileHistoryOpen
└─ 表单草稿、折叠状态、动画状态
各层职责
第一层:URL 身份层
唯一职责:回答”当前看的是哪个资源”。
const routeConversationId = id && id !== 'new' ? id : null;
- 是唯一身份源,不存在第二份”当前 ID”
- 侧边栏高亮用它
- 所有 query 的 enabled 条件用它
- 删除/新建后通过
navigate()改变它
第二层:Query 服务端数据层
唯一职责:管理来自后端 API 的数据。
const conversationsQuery = useQuery({ ... });
const conversationQuery = useQuery({ ... });
const consultationQuery = useQuery({ ... });
- 数据从 query.data 派生,不单独 useState
- loading/error 从 query 状态派生
- mutation 后通过 invalidate/setQueryData 更新
- SSE 增量通过 cancelQueries + setQueryData 写入
第三层:Streaming Runtime 层
唯一职责:管理正在流式生成的临时数据。
// 在 AssistantChatPanel 内部
const [streamingTokens, setStreamingTokens] = useState('');
const [pendingMessage, setPendingMessage] = useState<Message | null>(null);
- 流式 token 由 runtime 内部维护,不写 query cache
- 消息持久化后,再通过 callback 更新 query cache
- SSE 事件分发在这里完成
第四层:UI 状态层
唯一职责:管理纯前端交互状态。
const [mobileTab, setMobileTab] = useState<'chat' | 'info'>('chat');
const [isMobileHistoryOpen, setIsMobileHistoryOpen] = useState(false);
- 和后端无关
- 页面刷新后重置
- 不需要缓存、不需要同步、不需要失效
依赖路径
URL 身份层 (routeConversationId)
↓ 驱动
Query 服务端数据层 (useQuery enabled + queryKey)
↓ 提供数据给
组件渲染层
↓ 嵌入
Streaming Runtime 层 (AssistantChatPanel)
↓ 持久化后回调
Query 服务端数据层 (setQueryData)
状态归属速查
| 状态变量 | 应该在哪一层 | 迁移后 |
|---|---|---|
routeConversationId | 第一层 URL | 从 useParams() 派生 |
conversations | 第二层 Query | conversationsQuery.data |
currentConversation | 第二层 Query | conversationQuery.data?.conversation |
messages | 第二层 Query | conversationQuery.data?.messages |
extractedInfo | 第二层 Query | consultationQuery.data?.extracted_info |
phase | 第二层 Query | consultationQuery.data?.phase |
isLoading | 第二层 Query | conversationQuery.isPending |
error | 第二层 Query | conversationQuery.isError |
isAnalyzingDiagnosis | 第二层 Query | analyzeDiagnosisMutation.isPending |
chatSessionKey | 第一层派生 | routeConversationId ? \conversation:${routeConversationId}` : ‘new’` |
| 流式 token | 第三层 Runtime | AssistantChatPanel 内部 state |
mobileTab | 第四层 UI | useState |
isMobileHistoryOpen | 第四层 UI | useState |
这个架构解决的核心问题
迁移到四层架构后,删除全部会话时不会再出现:
setCurrentConversation(null)
→ useEffect 因 currentConversation 变化重新执行
→ URL 旧 id 还在
→ loadConversation(oldId)
因为:
- 不再有
setCurrentConversation(第二层从 query data 派生) - 不再有监听
currentConversation的 useEffect(数据由 query 驱动) - 删除后只做
navigate('/consultation')(第一层变) routeConversationId = null→enabled = false→ 不请求(第二层自然停)
不是”删除时清空一堆状态”,而是”删除后改变唯一身份源,其他状态自动失效”。
对比与易混淆点
| 误区 | 正确理解 |
|---|---|
| ”所有状态都该放 Zustand” | Zustand 管客户端共享状态,服务端数据用 Query |
| ”URL 只是导航用的” | URL 是身份源,决定”当前看的是什么" |
| "流式数据也该放 Query” | 流式 token 是临时的,放 runtime;持久化后才写 Query |
| ”useState 完全不能用” | UI 状态仍然用 useState,只是服务端状态不该用 |
| ”四层必须全部实现” | 没有流式交互的页面只需要三层(URL + Query + UI) |