四层状态架构

React 应用的状态分四层:URL 负责身份、Query 负责服务端数据、Streaming Runtime 负责流式临时数据、useState 负责 UI 状态。各层职责清晰,不互相替代。

#type / synthesis #status / growing #tech / dev / frontend #resource / react

[!info] related notes

四层状态架构

范围

适用于有路由、有 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第二层 QueryconversationsQuery.data
currentConversation第二层 QueryconversationQuery.data?.conversation
messages第二层 QueryconversationQuery.data?.messages
extractedInfo第二层 QueryconsultationQuery.data?.extracted_info
phase第二层 QueryconsultationQuery.data?.phase
isLoading第二层 QueryconversationQuery.isPending
error第二层 QueryconversationQuery.isError
isAnalyzingDiagnosis第二层 QueryanalyzeDiagnosisMutation.isPending
chatSessionKey第一层派生routeConversationId ? \conversation:${routeConversationId}` : ‘new’`
流式 token第三层 RuntimeAssistantChatPanel 内部 state
mobileTab第四层 UIuseState
isMobileHistoryOpen第四层 UIuseState

这个架构解决的核心问题

迁移到四层架构后,删除全部会话时不会再出现:

setCurrentConversation(null)
  → useEffect 因 currentConversation 变化重新执行
  → URL 旧 id 还在
  → loadConversation(oldId)

因为:

  1. 不再有 setCurrentConversation(第二层从 query data 派生)
  2. 不再有监听 currentConversation 的 useEffect(数据由 query 驱动)
  3. 删除后只做 navigate('/consultation')(第一层变)
  4. routeConversationId = nullenabled = false → 不请求(第二层自然停)

不是”删除时清空一堆状态”,而是”删除后改变唯一身份源,其他状态自动失效”。

对比与易混淆点

误区正确理解
”所有状态都该放 Zustand”Zustand 管客户端共享状态,服务端数据用 Query
”URL 只是导航用的”URL 是身份源,决定”当前看的是什么"
"流式数据也该放 Query”流式 token 是临时的,放 runtime;持久化后才写 Query
”useState 完全不能用”UI 状态仍然用 useState,只是服务端状态不该用
”四层必须全部实现”没有流式交互的页面只需要三层(URL + Query + UI)
创建于 2026/7/2 更新于 2026/7/15