React 状态管理先看作用域,不先选库

0阅读11分钟

一个拖慢整个应用的全局 store

去年我接手过一个中后台项目,技术栈是 React 18 + Zustand。团队在技术选型时决定把所有业务状态——列表数据、表单草稿、弹窗开关、用户偏好——全放进一个 store。上线半年后,运营反馈「表格输入越来越卡」。

我花了两天排查,用 React DevTools Profiler 跑了一遍,发现每次在表格中输入一个字符,整个页面有 47 个组件触发重渲染。根因很简单:store 里的 filters 对象被修改时,所有订阅了这个 store 的组件都会收到更新通知,即使它们只关心主题色或者侧边栏折叠状态。

这不是 Zustand 的问题,也不是 React 的性能瓶颈。问题出在状态的作用域划错了——本该属于某个组件的局部状态,被提升到了全局,导致一次修改引发大面积无效渲染。

Kent C. Dodds 在 State Colocation Will Make Your React App Faster 里说过一句话:「One of the leading causes of slow React applications is global state, especially the rapidly changing variety.」快速变化的全局状态是 React 应用变慢的首要原因之一。这不是理论推演,而是我在真实项目里踩过的坑。

这篇文章想聊的核心问题是:在讨论用哪个库之前,先搞清楚每块状态属于谁、活多久、谁能改。

状态分类的理论基础

React 的状态管理讨论有一个隐含前提:所有状态都是平等的。但实际上,不同状态的作用域、生命周期和变更频率完全不同,用同一套方案管理它们,必然会出现「状态放错地方」的问题。

Dan Abramov 在 2019 年的文章 How to Fetch Data with React Hooks 中首次明确提出「Server State」和「Client State」的区分。后来他在 TanStack Query 的推广中反复强调:大部分所谓的「状态管理问题」,其实是「服务端数据缓存和同步问题」。用全局 store 手动管理 API 返回的数据,本质上是在重复造一个简陋的缓存层。

Kent C. Dodds 在 Application State Management with React 中提出了另一个关键观点:状态的归属应该由组件树结构决定。如果一份状态只被一个组件用到,就该留在组件内部;如果被多个组件用到,提升到它们最近的公共祖先;只有当状态真正需要跨任意组件共享时,才考虑全局 store 或 Context。

这两个观点合在一起,形成了一个实用的分类框架:

状态类型典型例子生命周期作用域同步方式
局部 UI 状态弹窗开关、输入草稿、hover 状态随组件挂载/卸载单个组件或子树无需同步
URL 状态分页、筛选条件、搜索关键词随 URL 变化整个页面浏览器原生
服务端数据列表、详情、用户信息、权限随缓存策略过期按需共享服务端同步
全局客户端状态主题、语言、会话信息、全局通知随应用生命周期全应用本地存储/服务端

案例一:表单草稿不该出现在全局 store

场景

一个运营后台的商品编辑页面,表单有 30 多个字段,涉及图片上传、富文本编辑、价格计算等交互。团队把整个表单的状态放进 Zustand store,理由是「编辑页有多个子组件需要读写同一份数据」。

翻车

运营人员在编辑商品 A 的过程中,点击侧边栏切换到商品 B 的编辑页。由于 store 里的数据是全局共享的,商品 A 的未保存草稿和商品 B 的初始数据混在了一起。更糟的是,如果有两个浏览器 tab 同时打开编辑页,store 的状态完全隔离(每个 tab 有独立的 JS 上下文),但用户感知上是「两个独立页面」,这又导致了另一个问题:用户在 tab A 保存后切到 tab B,tab B 的 store 里仍然是旧数据。

核心问题是:表单草稿是一个有明确生命周期(从打开编辑到保存/取消)和作用域(当前编辑页)的状态,它不属于全局。

修复

将表单状态收回到编辑页组件内部,用 React Hook Form 管理局部表单状态。编辑页通过 URL 参数(/products/:id/edit)获取商品 ID,进入页面时从服务端拉取数据作为表单初始值。保存后通过 React Query 的 invalidateQueries 刷新列表缓存。

// ❌ 不好:表单草稿放在全局 store
// 问题:生命周期不受组件控制,切换页面时状态残留,需要手动清理
 
import { create } from 'zustand'
 
const useProductFormStore = create((set) => ({
  draft: null,
  isDirty: false,
  setDraft: (draft) => set({ draft, isDirty: true }),
  reset: () => set({ draft: null, isDirty: false }),
}))
 
// 编辑页组件里
function ProductEditPage({ productId }) {
  const { draft, setDraft, isDirty, reset } = useProductFormStore()
 
  // 每次进入页面都要手动检查并清理上一个草稿
  useEffect(() => {
    reset()
    fetchProduct(productId).then(setDraft)
  }, [productId])
 
  // 组件卸载时也要清理——如果忘了写就泄漏了
  useEffect(() => () => reset(), [])
}
// ✅ 好:表单状态留在编辑页组件内部
// 优势:组件卸载即销毁,不需要手动清理,生命周期天然正确
 
import { useForm } from 'react-hook-form'
import { useQuery, useQueryClient } from '@tanstack/react-query'
 
function ProductEditPage({ productId }) {
  const queryClient = useQueryClient()
 
  // 服务端数据用 React Query,缓存和刷新自动管理
  const { data: product } = useQuery({
    queryKey: ['product', productId],
    queryFn: () => fetchProduct(productId),
  })
 
  // 表单草稿用 React Hook Form,状态完全局部化
  const form = useForm({
    values: product, // 数据加载后自动填充
  })
 
  const onSubmit = async (values) => {
    await updateProduct(productId, values)
    // 保存成功后刷新列表缓存
    queryClient.invalidateQueries({ queryKey: ['products'] })
  }
 
  // 组件卸载时,所有表单状态自动销毁,无需手动清理
}

两组代码的核心差异不在 API 风格,而在状态归属:表单草稿属于「编辑页」这个组件,不是「整个应用」。把局部状态放进全局 store,等于把垃圾扔到了公共走廊——需要有人专门负责打扫。

案例二:URL 状态被当成组件状态

场景

一个数据分析平台的报表列表页,支持分页、关键词搜索、时间范围筛选和状态过滤。开发同学用 useState 管理所有筛选条件,页面交互流畅。

翻车

产品经理提了两个需求:一是用户筛选后想把链接分享给同事,同事打开应该看到相同的筛选结果;二是用户点击浏览器后退按钮时,筛选条件应该回到上一步。

useState 管理筛选条件时,这两个需求都无法满足。筛选条件只存在于 JS 内存中,URL 始终是 /reports,刷新页面后所有筛选条件丢失。

修复

将筛选条件同步到 URL 的 search params 中。在 Next.js 项目里用 useSearchParamsuseRouter,在 React Router 项目里用 useSearchParams。URL 状态天然是可分享的、可恢复的、支持浏览器前进后退的。

// ❌ 不好:筛选条件用 useState
// 问题:刷新丢失、无法分享、不支持浏览器后退
 
function ReportList() {
  const [page, setPage] = useState(1)
  const [keyword, setKeyword] = useState('')
  const [status, setStatus] = useState('all')
  const [dateRange, setDateRange] = useState(['2026-01-01', '2026-06-30'])
 
  // 这些状态在 URL 上完全不可见
  // 用户无法把「状态=进行中 + 关键词=Q2」这个视图分享给同事
}
// ✅ 好:筛选条件同步到 URL search params
// 优势:可分享、可恢复、支持浏览器后退
 
import { useSearchParams, useRouter } from 'next/navigation'
 
function ReportList() {
  const searchParams = useSearchParams()
  const router = useRouter()
 
  // 从 URL 读取状态,URL 就是单一数据源
  const page = Number(searchParams.get('page') || 1)
  const keyword = searchParams.get('keyword') || ''
  const status = searchParams.get('status') || 'all'
  const from = searchParams.get('from') || '2026-01-01'
  const to = searchParams.get('to') || '2026-06-30'
 
  const update = (key: string, value: string) => {
    const params = new URLSearchParams(searchParams.toString())
    params.set(key, value)
    // 用 router.push 保持浏览器历史记录,支持后退
    router.push(`?${params.toString()}`)
  }
 
  // 用户分享的 URL 包含完整筛选上下文
  // /reports?page=2&keyword=Q2&status=active&from=2026-01-01&to=2026-06-30
}
维度useState 管理筛选URL search params 管理筛选
刷新恢复❌ 丢失✅ 完整恢复
链接分享❌ 无法分享✅ 完整上下文
浏览器后退❌ 无效✅ 自然支持
SSR 兼容❌ 首次渲染无筛选✅ 服务端可直接读取
书签收藏❌ 只收藏空页面✅ 收藏带筛选的视图

案例三:服务端数据放进全局 store 的缓存陷阱

场景

一个电商后台的订单管理模块,订单列表和订单详情都在同一个 Zustand store 里缓存。团队自己实现了一套缓存逻辑:数据加载后存入 store,设置一个 fetchedAt 时间戳,组件读取时判断是否过期。

翻车

订单列表页和订单详情页各自有缓存逻辑,但它们的过期判断是独立的。用户在详情页修改了一个订单的状态,返回列表页时,列表缓存还没过期,显示的仍然是旧状态。用户手动刷新后才能看到正确数据。

更大的问题是,这份「缓存」不支持失效通知、不支持乐观更新、不支持后台重新验证。团队实际上在 store 里重新实现了一个功能不全的 React Query。

修复

服务端数据统一交给 TanStack Query(React Query)管理。Zustand store 只保留真正的客户端状态(比如当前选中的 tab、展开/折叠状态)。

// ❌ 不好:在全局 store 里手动管理服务端缓存
// 问题:缓存过期逻辑分散,跨组件缓存不一致,不支持失效通知
 
import { create } from 'zustand'
 
const useOrderStore = create((set, get) => ({
  orders: [],
  currentOrder: null,
  ordersFetchedAt: 0,
  currentOrderFetchedAt: 0,
 
  fetchOrders: async () => {
    const orders = await api.getOrders()
    set({ orders, ordersFetchedAt: Date.now() })
  },
 
  fetchCurrentOrder: async (id) => {
    const order = await api.getOrder(id)
    set({ currentOrder: order, currentOrderFetchedAt: Date.now() })
  },
 
  // 详情页更新了订单,但列表的 ordersFetchedAt 没有变
  // 返回列表页时缓存判断「还没过期」,数据不一致
  updateOrder: async (id, data) => {
    const updated = await api.updateOrder(id, data)
    set({ currentOrder: updated })
  },
}))
// ✅ 好:服务端数据用 TanStack Query,缓存一致性由框架保证
// 优势:自动失效、后台重新验证、乐观更新、跨组件缓存同步
 
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
 
// 列表页
function OrderList() {
  const { data: orders, isLoading } = useQuery({
    queryKey: ['orders'],
    queryFn: () => api.getOrders(),
    staleTime: 30_000, // 30秒内视为新鲜数据
  })
 
  if (isLoading) return <Skeleton />
  return <OrderTable orders={orders} />
}
 
// 详情页
function OrderDetail({ orderId }) {
  const queryClient = useQueryClient()
 
  const { data: order } = useQuery({
    queryKey: ['order', orderId],
    queryFn: () => api.getOrder(orderId),
  })
 
  // mutation 完成后自动让相关缓存失效
  const mutation = useMutation({
    mutationFn: (data) => api.updateOrder(orderId, data),
    onSuccess: () => {
      // 列表和详情的缓存同时失效,下次访问自动重新拉取
      queryClient.invalidateQueries({ queryKey: ['orders'] })
      queryClient.invalidateQueries({ queryKey: ['order', orderId] })
    },
  })
}
能力手动 store 缓存TanStack Query
跨组件缓存同步需手动协调invalidateQueries 一行搞定
后台重新验证不支持refetchOnWindowFocus 原生支持
乐观更新需手动回滚onMutate + onError 回滚模式
缓存去重需手动判断相同 queryKey 自动去重
分页/无限滚动需手动拼接useInfiniteQuery 内置
包大小Zustand ~3KBTanStack Query ~14KB

TanStack Query 的包大小确实比 Zustand 大一些,但它解决的是服务端数据缓存问题。如果团队选择在 store 里手动实现等价的缓存逻辑,代码量和出错概率远超引入一个成熟库的成本。

状态作用域决策流程

流程图画布 · 115%
Mermaid 流程图加载中...

这个流程图的核心逻辑是层层收窄:先看数据来源(服务端还是客户端),再看共享范围(全应用还是局部),最后看生命周期(持久化还是临时的)。每一步都在缩小候选方案的范围。

四种状态管理方案的适用边界

维度useState / useReducerContext + useReducerZustand / JotaiTanStack Query
适用状态类型局部 UI 状态中等深度的组件树共享状态全局客户端状态服务端数据
包大小0(内置)0(内置)Zustand ~3KB / Jotai ~4KB~14KB
渲染性能组件级更新整个 Consumer 子树重渲染选择性订阅,精确更新按 queryKey 隔离
学习曲线极低
DevTools 支持React DevToolsReact DevToolsZustand 支持 Redux DevToolsTanStack Query DevTools
SSR 兼容需要 Provider 包裹需要特殊处理
代码分割友好❌ Context 值无法分割Jotai 天然支持

状态提升的度在哪里

Kent C. Dodds 在 My State Management Mistake 中分享过一个经验:他曾经在一个项目里过早地把大量状态提升到顶层 Context,导致性能问题。他的反思是,状态提升应该是「最后手段」而不是「默认操作」。

实际项目里,我遵循三条规则来判断是否该提升状态:

规则一:先用 useState,不够再提升。 如果一个状态只在一个组件里用到,就用 useState。哪怕这个组件有 500 行代码,只要状态没有被其他组件消费,就不要动它。

规则二:提升到最近的公共祖先,不多提一层。 如果组件 A 和组件 B 共享一份状态,状态应该放在 A 和 B 的最近公共父组件里,而不是直接放到应用根节点。

规则三:Context 只在「变化不频繁」的全局状态上使用。 Context 的更新会导致所有 Consumer 重渲染。主题色、语言、用户信息这类变化很少的状态适合 Context;输入框值、滚动位置这类频繁变化的状态不适合。

// ❌ 不好:把主题和用户信息放在同一个 Context
// 问题:主题切换时,只关心用户信息的组件也会重渲染
 
const AppContext = React.createContext(null)
 
function AppProvider({ children }) {
  const [theme, setTheme] = useState('light')
  const [user, setUser] = useState(null)
 
  return (
    <AppContext.Provider value={{ theme, setTheme, user, setUser }}>
      {children}
    </AppContext.Provider>
  )
}
// ✅ 好:按变化频率拆分 Context
// 优势:主题切换不影响用户信息的消费者
 
const ThemeContext = React.createContext(null)
const UserContext = React.createContext(null)
 
// 主题频繁切换(用户可能反复点),但不影响用户信息消费者
function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('light')
  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      {children}
    </ThemeContext.Provider>
  )
}
 
// 用户信息登录后几乎不变,独立管理
function UserProvider({ children }) {
  const [user, setUser] = useState(null)
  return (
    <UserContext.Provider value={{ user, setUser }}>
      {children}
    </UserContext.Provider>
  )
}

Zustand vs Jotai:全局 store 的两种哲学

当状态确实需要全局共享时,Zustand 和 Jotai 代表了两种不同的设计哲学。

Zustand 是「单 store 模型」:一个 store 包含所有全局状态,通过 selector 订阅需要的部分。它更像一个传统的状态容器,概念简单,API 直接。适合状态之间有复杂关联、需要在组件外部更新状态(比如 WebSocket 回调里更新 store)的场景。

Jotai 是「原子模型」:每个状态是一个独立的 atom,组件只订阅用到的 atom。它更接近 React 的心智模型,天然支持代码分割(每个 atom 可以独立定义在不同文件中),与 React Suspense 集成更好。适合状态之间相对独立、需要细粒度订阅、大量异步派生状态的场景。

维度ZustandJotai
状态模型单 store,selector 订阅多 atom,按 atom 订阅
包大小~3KB~4KB
组件外更新直接调用 store.getState()需要用 store.set()
代码分割需要手动拆分 storeatom 天然支持
Suspense 集成需要手动处理原生支持
DevToolsRedux DevTools需要额外配置
团队上手极低,API 最少低,但 atom 组合需要学习

我的经验是:如果团队之前的状态管理偏简单(Context 或 useState),升级到 Zustand 的学习成本最低。如果项目状态结构复杂、需要大量派生状态或异步状态,Jotai 的原子模型更灵活。

状态管理检查清单

在新项目或重构现有项目时,我会按以下清单逐项检查:

设计阶段

  • 每份状态已明确归类为:局部 UI / URL / 服务端数据 / 全局客户端状态
  • 服务端数据统一由 TanStack Query(或等价方案)管理,不在 client store 里重复缓存
  • 可分享的视图状态(筛选、分页、排序)已放到 URL search params
  • 全局 store 只包含真正跨页面共享的客户端状态
  • 每份状态有且仅有一个「主人」——它属于某个组件、某个 hook 或某个 store

实现阶段

  • 局部状态使用 useState 或 useReducer,未过度抽象为自定义 hook
  • Context 按变化频率拆分,高频变化和低频变化不在同一个 Context
  • Zustand store 使用 selector 订阅,避免组件订阅整个 store
  • TanStack Query 的 queryKey 遵循一致的命名规范,包含所有影响查询结果的参数
  • 表单状态使用专用库(React Hook Form)或组件内 useReducer,不放全局 store

验证阶段

  • 用 React DevTools Profiler 检查高频交互(输入、拖拽、滚动)时的重渲染范围
  • 确认页面刷新后 URL 状态可恢复
  • 确认服务端数据更新后所有相关视图同步刷新
  • 确认组件卸载后不存在残留的全局状态需要手动清理

总结

状态管理的核心问题不是「用哪个库」,而是「这份状态属于谁」。

局部状态留在组件里,URL 状态放在地址栏里,服务端数据交给缓存层,全局客户端状态才值得放进 store。这个分类做完之后,技术方案的选择几乎是自动的——你甚至不需要在 Zustand、Jotai 和 Redux 之间纠结太久,因为适合每类状态的工具就那么几个。

过度提升状态是 React 应用变慢和变复杂的常见原因。先把状态放对位置,再考虑要不要用工具优化。

参考资料

评论 0

0 / 1000