Notes

状态分层: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. 一个可执行的决策流程(放置原则)

当你遇到一个“需要状态”的需求时,按下面顺序问:

  1. 它是否必须出现在 URL 里?(可分享/可回放/可回退)
  • 是 → URL State(路由)
  1. 它是否来自服务端,并需要缓存一致性?
  • 是 → Server State(Query cache)
  1. 它是否跨路由共享,且与具体页面弱绑定?
  • 是 → Global State(store/context)
  1. 否则 → 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 校验/默认值确保“链接可还原”
  • 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. 常见反模式(踩一次就会懂)

  1. 把 URL state 复制一份到 store
  • 后果:back/forward 不可回放;分享链接不一致;两处同步复杂
  1. 把 Server state 塞进全局 store
  • 后果:你要自己实现缓存、失效、去重、重试、分页…最后重复造 TanStack Query
  1. 把高频 UI state 放全局
  • 后果:每次输入导致全局订阅者 re-render,性能灾难

7. Checklist

  • 这个状态的 source of truth 是什么?(只能有一个)
  • 它是否必须可分享/可回放?如果是,是否进 URL?
  • 它是否来自服务端并需要缓存一致性?如果是,是否交给 Query?
  • 它是否跨路由共享?如果是,Global state 的订阅粒度是否足够细?
  • 是否存在同一数据域的“多源真相”?(最危险)

关联阅读

cd ..