组件内服务端状态与客户端状态的区分
React 组件内的 state 混合了来自 API 的服务端状态和纯前端的 UI 状态,区分它们是避免 useEffect 竞态和手动缓存层的关键。
#type / concept
#status / growing
#tech / dev / frontend
#resource / react
[!info] related notes
- 所属 MOC: React MOC
- 前置概念: useState
- 并列概念: React 状态管理选型
- 实践方案: TanStack Query 服务端状态
- 架构: 四层状态架构
组件内服务端状态与客户端状态的区分
一句话定义
组件里用 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— 当前 tabisMobileHistoryOpen— 侧边栏是否展开- 表单草稿、折叠状态、动画状态
为什么混合管理会出问题
把服务端状态也当普通 useState 维护,组件就需要自己处理:
- 什么时候加载
- 什么时候重新加载
- 什么时候清空
- 什么时候避免重复请求
- 什么时候处理 404
- 什么时候防止旧请求覆盖新状态
- 什么时候同步列表和详情
这些逻辑越写越多,就变成组件内部手写了一个”简陋数据缓存层”,容易出现竞态。
典型竞态场景
// 删除全部会话后
setCurrentConversation(null); // 1. 清空当前会话
// useEffect 因 currentConversation 变化重新执行
// 但 URL 里的旧 id 还在
loadConversation(oldId); // 2. 又去请求已删除的会话
根因是 currentConversation 同时承担了两个职责:“当前会话是谁” 和 “当前会话数据是什么”。
正确的分层
服务端状态 → 交给 TanStack Query / React Router Loader
客户端 UI 状态 → 留在 useState
服务端状态的主来源应该是 query cache,不是组件 state。组件只消费 data、isLoading、error、mutate。
最短记忆方式
useState 里的数据,问自己:“这个数据后端也有吗?” 如果有,它应该归 TanStack Query 管。
边界与易混淆点
isLoading/error看起来像 UI 状态,但它们描述的是”请求”的状态,应该由数据层提供isAnalyzingDiagnosis/isGeneratingTreatment看起来像业务状态,但它们描述的是 mutation 的状态,应该由useMutation.isPending提供- 不是说永远不能
setExtractedInfo,而是说主来源应该是 query cache,局部乐观更新是例外