状态分层:UI / Server / URL / Global(架构师视角)
一句话结论:绝大多数 React 项目之所以“越写越乱”,根因不是缺少某个状态库,而是没有把状态按来源与生命周期分层——把 UI state、Server state、URL state、Global state 的边界划清后,你的路由、缓存、表单、权限、性能优化都会自然收敛。
1. 为什么要做状态分层?
项目变大后,常见症状是:
- 同一份数据同时存在于:组件 state / 全局 store / URL / Query cache(多源真相)
- 页面刷新、返回、分享链接后状态丢失或错乱
- “改一个筛选条件”导致一堆组件 re-render,但没人能解释为什么
- 线上问题难复现:因为状态分布在各处且缺乏可观测性
状态分层要解决的是一句话:
每一类状态只允许有一个“事实来源(source of truth)”,并且明确它的生命周期与同步策略。
2. 四类状态的定义(以及最容易混的边界)
infographic list-grid-badge-card
data
title React 状态分层(四类)
items
- label UI State
desc 纯前端交互与展示状态(是否展开、当前 tab、输入框即时值)
icon mdi:gesture-tap
- label Server State
desc 来自服务端的数据及其缓存一致性(列表/详情/权限配置)
icon mdi:cloud-download
- label URL State
desc 需要“可分享/可回放”的状态(筛选/分页/排序/选中项)
icon mdi:link-variant
- label Global State
desc 跨页面/跨模块共享且与具体路由弱相关的状态(用户会话、feature flags)
icon mdi:earth
2.1 UI State(组件/局部状态)
典型例子:
isOpen/activeTab- 输入框即时值
text - hover/focus、modal 打开状态
特征:
- 生命周期短,通常不需要跨页面持久化
- 频繁变化,越靠近组件越好(避免全局更新导致无谓渲染)
2.2 Server State(服务端数据状态)
典型例子:
- 列表/详情数据
- 用户权限列表、配置下发
特征:
- 真相在服务端
- 需要缓存、去重、失效、重试、分页、预取
- 最佳实践通常是:用专门的 Server State 库管理(TanStack Query / SWR 等),而不是塞进全局 store
2.3 URL State(路由可回放状态)
典型例子:
?keyword=...&page=2&sort=price_desc- 选中的 itemId
特征:
- 需要可分享(复制链接给别人应还原视图)
- 需要可回退(浏览器 back/forward 应还原视图)
- 一般和路由强绑定:适合由路由层(TanStack Router / Next App Router)管理
2.4 Global State(跨路由共享状态)
典型例子:
- 登录会话(user/session)
- feature flags / AB 实验开关
- 全局主题/语言
特征:
- 跨页面共享
- 变化频率通常低(否则会导致大面积 re-render)
- 需要良好的“订阅粒度”(selector)与模块边界
3. 一个可执行的决策流程(放置原则)
当你遇到一个“需要状态”的需求时,按下面顺序问:
- 它是否必须出现在 URL 里?(可分享/可回放/可回退)
- 是 → URL State(路由)
- 它是否来自服务端,并需要缓存一致性?
- 是 → Server State(Query cache)
- 它是否跨路由共享,且与具体页面弱绑定?
- 是 → Global State(store/context)
- 否则 → UI State(组件/局部)
这套流程的核心是:尽量不要“为了方便”把东西放进全局 store。
4. 典型业务案例:搜索列表页(推荐拆法)
以“商品搜索列表”为例:
- UI State
- 输入框即时值
text(为了输入流畅,甚至可与 Transition 配合) - 侧边栏展开/收起
isFilterOpen
- 输入框即时值
- URL State
keyword/page/sort/filters(必须可分享、可回退)
- Server State
products列表数据(由 URL state 派生出来的查询条件驱动)
- Global State
session/user(用户信息、登录态)featureFlags(实验开关)
关键约束:
- 查询条件的单一真相在 URL(不是 store、不是组件 state)
- 数据的单一真相在 Query cache(不是 store)
5. 两套技术栈下的落地方式
5.1 TanStack Router + Vite
- URL State:
- 用 TanStack Router 的
search params作为 query 条件 - 通过 schema 校验/默认值确保“链接可还原”
- 用 TanStack Router 的
- Server State:
- 用 TanStack Query 管理
- 可以在 route loader 里 prefetch(让导航更快)
5.2 Next.js App Router
- URL State:
searchParams是天然的 URL state- 适合把筛选/分页做成可分享链接
- Server State:
- 如果数据在 Server Components 获取:它更像“框架级 Server State + 缓存”(与 Next 缓存/重验证绑定)
- 如果数据在 Client Components 获取:仍建议用 TanStack Query 管理缓存一致性
架构建议:不要在同一个数据域里混用多套“真相来源”。
6. 常见反模式(踩一次就会懂)
- 把 URL state 复制一份到 store
- 后果:back/forward 不可回放;分享链接不一致;两处同步复杂
- 把 Server state 塞进全局 store
- 后果:你要自己实现缓存、失效、去重、重试、分页…最后重复造 TanStack Query
- 把高频 UI state 放全局
- 后果:每次输入导致全局订阅者 re-render,性能灾难
7. Checklist
- 这个状态的 source of truth 是什么?(只能有一个)
- 它是否必须可分享/可回放?如果是,是否进 URL?
- 它是否来自服务端并需要缓存一致性?如果是,是否交给 Query?
- 它是否跨路由共享?如果是,Global state 的订阅粒度是否足够细?
- 是否存在同一数据域的“多源真相”?(最危险)
关联阅读
- TanStack Query 缓存与失效(下一篇):
./02-tanstack-query-caching-and-invalidation.md - 路由与 URL state(章节):
../10-routing-and-navigation/README.md - React 18 Transition(输入与结果区拆分):
../09-react-18-in-practice/03-transition-useTransition-useDeferredValue.md