组件内服务端状态与客户端状态的区分
区分 React 组件中的 server-owned projection 与 client-owned UI state;判断 ownership 以 authority/lifetime 为准,而不是看值存放在 useState、Zustand 还是 Query Cache。
[!info] related notes
- 所属 MOC: React MOC
- 前置概念: useState
- 并列概念: React 状态管理选型
- 实践方案: TanStack Query 服务端状态
- 架构: 四层状态架构
组件内服务端状态与客户端状态的区分
一句话定义
React 里的“state”不都由前端拥有。来自服务器的数据即使被放进 useState 或 Zustand,也通常只是 server-owned fact 的客户端 projection;Dialog、Accordion、未提交表单等才是典型 client-owned UI state。
最重要的判断:冲突时谁裁决
不要只问:
这个值现在存在哪?
要问:
如果客户端和服务器两份值冲突,谁有资格决定哪一份是真的?
例如:
Query cache: revision = 42
Server: revision = 43
客户端必须把 42 当成 stale projection,而不是把服务器改回 42。
所以:
Storage/container
≠ ownership/authority
常见五类状态
A. Local component state
Dialog open、Accordion、未提交输入
B. URL state
route resource id、分页、可分享筛选
C. Shared client presentation state
sidebar collapsed、主题、跨组件 UI workflow
D. Server state/cache projection
/api/me、HealthWorkspace、conversation list
E. Durable domain truth
DB 中的用户/Workspace、服务器 current_revision
D 和 E 内容可能一模一样,但 ownership 不一样:D 是缓存/投影,E 是权威事实。
为什么服务端数据不适合复制成独立 useState
例如:
const [conversation, setConversation] = useState<Conversation | null>(null);
useEffect(() => {
fetchConversation(id).then(setConversation);
}, [id]);
很快就需要手工处理:
loading/error
retry
request dedup
stale data
old response race
refetch
mutation synchronization
cache invalidation
这等于在组件里重写一个简陋的 server-state synchronization layer。
因此常见做法是:
server projection
→ TanStack Query / Router loader / dedicated data layer
local UI state
→ useState/useReducer
但不要把规则背成“后端有就一定 TanStack Query”
TanStack Query 是常见而合适的 server-state cache/sync 工具,但这不是 ownership 定义本身。
例如 /api/me 的 user projection 可能因为认证流程被暂存在 Zustand;这仍然是 server-owned data stored in a client container。
真正危险的是:
server copy
+
query copy
+
zustand copy
都能被客户端独立修改,却没有明确 reconciliation 规则。
Identity 与 Data 也要分开
一个常见竞态是同时维护:
URL: /conversation/abc
+
currentConversation.id = xyz
这形成两套“当前资源是谁”的 identity source。
更稳的是:
URL/Router
→ current resource identity
Query
→ data for that identity
见 URL 作为唯一身份源。
Query lifecycle state 不是普通 UI state
isPending、isFetching、error 虽然最终会影响 UI,但它们描述的是 Query 生命周期,通常应由数据层提供,而不是组件再复制一份:
isPending
≈ 尚无成功可用的 Query 数据状态
isFetching
≈ 当前有 fetch 正在执行
已有缓存时可以:
data exists
isFetching = true
此时不应该因为后台 refetch 就清空页面内容。
Local / Shared Client State 什么时候升级
只在一个组件交互
→ useState/useReducer
多个远距离组件需要共享,且由客户端拥有
→ Context/Zustand/Redux 等
可导航、可分享、刷新应恢复
→ URL/Router
不要因为“以后可能会用”就把局部状态提前全局化。
最短记忆
先问 owner,再选 container。
Local UI → component
URL identity→ router
Shared UI → client store
Server data → server-state cache/projection
Durable fact→ server
如果副本冲突,谁裁决,谁才是 authority。