React 状态管理先看作用域,不先选库
一个拖慢整个应用的全局 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 项目里用 useSearchParams 和 useRouter,在 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 ~3KB | TanStack Query ~14KB |
TanStack Query 的包大小确实比 Zustand 大一些,但它解决的是服务端数据缓存问题。如果团队选择在 store 里手动实现等价的缓存逻辑,代码量和出错概率远超引入一个成熟库的成本。
状态作用域决策流程
这个流程图的核心逻辑是层层收窄:先看数据来源(服务端还是客户端),再看共享范围(全应用还是局部),最后看生命周期(持久化还是临时的)。每一步都在缩小候选方案的范围。
四种状态管理方案的适用边界
| 维度 | useState / useReducer | Context + useReducer | Zustand / Jotai | TanStack Query |
|---|---|---|---|---|
| 适用状态类型 | 局部 UI 状态 | 中等深度的组件树共享状态 | 全局客户端状态 | 服务端数据 |
| 包大小 | 0(内置) | 0(内置) | Zustand ~3KB / Jotai ~4KB | ~14KB |
| 渲染性能 | 组件级更新 | 整个 Consumer 子树重渲染 | 选择性订阅,精确更新 | 按 queryKey 隔离 |
| 学习曲线 | 极低 | 低 | 低 | 中 |
| DevTools 支持 | React DevTools | React DevTools | Zustand 支持 Redux DevTools | TanStack 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 集成更好。适合状态之间相对独立、需要细粒度订阅、大量异步派生状态的场景。
| 维度 | Zustand | Jotai |
|---|---|---|
| 状态模型 | 单 store,selector 订阅 | 多 atom,按 atom 订阅 |
| 包大小 | ~3KB | ~4KB |
| 组件外更新 | 直接调用 store.getState() | 需要用 store.set() |
| 代码分割 | 需要手动拆分 store | atom 天然支持 |
| Suspense 集成 | 需要手动处理 | 原生支持 |
| DevTools | Redux 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 应用变慢和变复杂的常见原因。先把状态放对位置,再考虑要不要用工具优化。
参考资料
- Application State Management with React — Kent C. Dodds,关于状态与组件树结构映射的核心论述
- State Colocation Will Make Your React App Faster — Kent C. Dodds,状态共置与渲染性能的关系
- My State Management Mistake — Kent C. Dodds,过早提升状态的经验教训
- How to Fetch Data with React Hooks — Dan Abramov,Server State 与 Client State 的概念起源
- TanStack Query 官方文档 — 服务端状态管理的标准实现
- Zustand 官方文档 — 轻量级全局状态管理库
- Jotai 官方文档 — 原子化状态管理库,与 React Suspense 深度集成
- State Management in 2026: Zustand vs Jotai vs Redux Toolkit vs Signals — 2026 年状态管理方案综合对比