组件内服务端状态与客户端状态的区分

React 组件内的 state 混合了来自 API 的服务端状态和纯前端的 UI 状态,区分它们是避免 useEffect 竞态和手动缓存层的关键。

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

[!info] related notes

组件内服务端状态与客户端状态的区分

一句话定义

组件里用 useState 维护的数据,有些来自后端 API(服务端状态),有些是纯前端交互产生的(客户端 UI 状态)。把它们混在一起管理,会导致手动缓存、竞态和冗余重载。

核心问题

一个典型的 AI 对话页面可能有这些 state:

const [conversations, setConversations] = useState<Conversation[]>([]);
const [currentConversation, setCurrentConversation] = useState<Conversation | null>(null);
const [messages, setMessages] = useState<Message[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [mobileTab, setMobileTab] = useState<'chat' | 'info'>('chat');
const [isMobileHistoryOpen, setIsMobileHistoryOpen] = useState(false);

这些状态本质上分两类:

服务端状态

来自后端 API,有独立的生命周期:

  • conversations — 会话列表
  • currentConversation — 当前会话详情
  • messages — 消息列表
  • extractedInfo / phase / diagnoses / treatmentPlan — 业务领域数据
  • isLoading / error — 请求状态

客户端 UI 状态

纯前端交互产生,和后端无关:

  • mobileTab — 当前 tab
  • isMobileHistoryOpen — 侧边栏是否展开
  • 表单草稿、折叠状态、动画状态

为什么混合管理会出问题

把服务端状态也当普通 useState 维护,组件就需要自己处理:

  • 什么时候加载
  • 什么时候重新加载
  • 什么时候清空
  • 什么时候避免重复请求
  • 什么时候处理 404
  • 什么时候防止旧请求覆盖新状态
  • 什么时候同步列表和详情

这些逻辑越写越多,就变成组件内部手写了一个”简陋数据缓存层”,容易出现竞态。

典型竞态场景

// 删除全部会话后
setCurrentConversation(null);       // 1. 清空当前会话
// useEffect 因 currentConversation 变化重新执行
// 但 URL 里的旧 id 还在
loadConversation(oldId);            // 2. 又去请求已删除的会话

根因是 currentConversation 同时承担了两个职责:“当前会话是谁” 和 “当前会话数据是什么”。

正确的分层

服务端状态 → 交给 TanStack Query / React Router Loader
客户端 UI 状态 → 留在 useState

服务端状态的主来源应该是 query cache,不是组件 state。组件只消费 dataisLoadingerrormutate

最短记忆方式

useState 里的数据,问自己:“这个数据后端也有吗?” 如果有,它应该归 TanStack Query 管。

边界与易混淆点

  • isLoading / error 看起来像 UI 状态,但它们描述的是”请求”的状态,应该由数据层提供
  • isAnalyzingDiagnosis / isGeneratingTreatment 看起来像业务状态,但它们描述的是 mutation 的状态,应该由 useMutation.isPending 提供
  • 不是说永远不能 setExtractedInfo,而是说主来源应该是 query cache,局部乐观更新是例外
创建于 2026/7/2 更新于 2026/7/15