现代前端状态管理踩坑记录
登录态在 Header 里要用,结算页也要用,中间还隔着五层 Layout——props 一层层往下传,改个字段全项目找引用。
2024 年重构一个 React 中台项目时,Redux 的样板代码已经明显拖慢迭代:加个字段要动 action、reducer、类型三处。团队讨论后迁到 Zustand,代码量大概砍了一半。但回头看,当时缺的不是「Zustand 教程」,而是 状态管理到底在管什么、什么场景绕不过去、Redux 和 Zustand 各自解决哪一层问题。这篇先把这些补全,再写迁移和踩坑。
前端状态管理到底在解决什么
React 组件有自己的 useState,本地状态够用时什么都不用引。麻烦出在 同一份数据要被多处读写,或者读写路径跨很多层组件 的时候。
典型症状:
- Props Drilling:用户信息、权限、主题从 App 传到叶子组件,中间层根本不关心这些数据,只是充当管道
- 多源不一致:A 组件改了购物车数量,B 组件还显示旧值,因为没有统一写入入口
- 异步状态难管:请求 loading、error、缓存、重试散落在各个 hook 里,页面一复杂就拼不起来
- 调试和回溯困难:「这个值是谁改的、改之前是什么」在分散的
setState里很难追
状态管理要回答的就是:这类数据放哪、怎么读、怎么写、怎么通知订阅者更新 UI。它不是某个库的专利——useState、React Context、Redux、Zustand、Jotai、TanStack Query 都在管状态,只是 管的范围和规则不同。
可以粗分三类:
| 类型 | 例子 | 核心诉求 |
|---|---|---|
| UI 状态 | 弹窗开闭、Tab 选中、输入框草稿 | 生命周期短,多半留在组件内 |
| 客户端全局状态 | 登录用户、权限、主题、购物车 | 跨页面共享,写入路径要统一 |
| 服务端状态 | 列表数据、详情、分页缓存 | 来自 API,重点是缓存、失效、重拉 |
很多项目把三类全塞进 Redux,才会觉得「太重」。状态管理选型,首先是分桶,不是先选库。
什么场景绕不过去
下面这些情况,单靠组件内 useState 通常会很快撞墙——不是不能硬写,而是维护成本会指数上去:
| 场景 | 为什么需要集中管理 | 常见做法 |
|---|---|---|
| 登录态 / 权限 | 全站几十处要读,登出时要一次性清空 | Context、Zustand、Redux |
| 购物车 / 草稿箱 | 跨路由保留,刷新后还要恢复 | 全局 store + persist |
| 复杂表单 / 向导 | 多步之间共享字段,中间步骤只读部分数据 | useReducer、Zustand、Form 库 |
| 实时协作 / 通知 | WebSocket 推来的消息要驱动多个面板 | 全局 store 或事件总线 |
| 后台列表 + 详情联动 | 列表筛选项、选中行、详情缓存要同步 | 服务端状态库 + 少量全局 UI 状态 |
不得不上全局状态管理,通常满足其中两条:
- 同一份数据被 3 个以上互不相关的组件树读写——再 props 传递就是人为制造耦合。
- 写入逻辑有业务规则——例如登出清缓存、加购时校验库存,需要单一入口而不是到处
setState。 - 你需要可观测性——时间旅行调试、埋点、回放用户操作路径(Redux DevTools 这类需求)。
可以先不上的情况同样明确:
- 页面级状态,父子两层就能覆盖
- 数据几乎全来自服务端,客户端只关心「请求中 / 成功 / 失败」——优先 TanStack Query,而不是再造一个全局 store
- 团队规模小、页面少,Context +
useReducer够用
我们那个中台项目属于第一类:权限、当前租户、侧边栏折叠、若干跨模块的配置项,读写点分散在 Layout、列表页、详情抽屉里,继续 props 传下去不现实。
Redux 是什么,适合干什么
Redux 是一个 可预测的全局状态容器,核心约束三条:
- 单一 Store:应用级客户端状态集中存放
- 只读状态树:不能直接改
state,只能dispatch(action) - 纯函数 Reducer:
(state, action) => newState,相同输入必得相同输出
原来项目里的 Redux 代码大概长这样:
// actions.js
export const increment = () => ({ type: 'INCREMENT' })
export const decrement = () => ({ type: 'DECREMENT' })
export const setValue = (value) => ({ type: 'SET_VALUE', payload: value })
// reducer.js
const initialState = { count: 0, value: 0 }
export default function counterReducer(state = initialState, action) {
switch (action.type) {
case 'INCREMENT':
return { ...state, count: state.count + 1 }
case 'DECREMENT':
return { ...state, count: state.count - 1 }
case 'SET_VALUE':
return { ...state, value: action.payload }
default:
return state
}
}
// store.js
import { createStore, combineReducers } from 'redux'
import counterReducer from './reducer'
const store = createStore(combineReducers({
counter: counterReducer
}))
export default store
改个字段要动 action、reducer、有时还有 selector 和类型定义——这是设计使然,不是 Redux 写错了。它用显式 action 换可追溯性和测试友好:给定 action 序列,状态变化可复现。
Redux 真正强的场景:
- 状态变更路径多、参与模块多,需要统一约束写入方式
- 中间件生态:Thunk/Saga 处理复杂异步,logger、持久化、DevTools 成熟
- 大团队协作:action type 即文档,code review 时容易看出「谁在什么条件下改了什么」
代价也直白:模板代码多,小功能也要走完整链路;配合 TypeScript 时类型体操不少;服务端列表缓存这类场景用 Redux 自己拼,往往不如专用数据请求库省事。
Zustand 是什么,为什么这次选它
Zustand(德语「状态」)是偏轻量的 全局 store 库,API 接近「一个 hook 管一片状态」:
- 不强制 action/reducer 分层,在
create回调里直接定义状态和改法 - 默认基于 订阅 + 浅比较,组件按 selector 精确订阅字段
- 不依赖 React Context 传递 store,避免大 Provider 树和 Context 值变化导致整棵子树重渲染
迁移后同等功能大概是这样:
import { create } from 'zustand'
const useStore = create((set) => ({
count: 0,
value: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
setValue: (value) => set({ value }),
}))
export default useStore
组件里使用:
import useStore from './store'
function Counter() {
const { count, increment, decrement } = useStore()
return (
<div>
<span>{count}</span>
<button onClick={increment}>+</button>
<button onClick={decrement}>-</button>
</div>
)
}
和 Redux 比,差异不在「能不能管全局状态」,而在 默认约定的严格程度:
| 维度 | Redux | Zustand |
|---|---|---|
| 改状态的入口 | 必须 dispatch action | 直接调 store 里的方法 |
| 结构约束 | reducer 纯函数、action 可序列化 | 约定宽松,靠团队自律 |
| 样板代码 | action + reducer + 常量多文件 | 常单文件搞定 |
| 异步 | 中间件(Thunk/Saga) | 方法里直接 async/await |
| 调试 | DevTools 体验成熟 | 有 devtools 中间件,深度略浅 |
| 细粒度订阅 | 需配合 selector + 记忆化 | selector 是一等公民 |
我们选 Zustand,是因为项目 全局状态以 UI 和会话为主,服务端列表已逐步迁到 React Query;团队更小,更在意交付速度而不是 action 审计链。若未来出现复杂工作流、要严格 replay 的状态机,会再评估 Redux Toolkit 或 XState,而不是假设 Zustand 包打天下。
Zustand 落地时值得知道的点
按需订阅,避免整 store 重渲染
function Counter() {
const count = useStore((state) => state.count)
const increment = useStore((state) => state.increment)
return <div onClick={increment}>{count}</div>
}
只订阅 count 时,改 value 不会触发这个组件更新——这是 Zustand 相对「整包解构 useStore()」的性能优势。
中间件并不弱
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
const useStore = create(
devtools(
persist(
(set) => ({
tenantId: null,
setTenantId: (id) => set({ tenantId: id }),
}),
{ name: 'session-store' }
)
)
)
persist 做本地持久化(租户 ID、侧边栏状态这类),devtools 接浏览器插件——对我们够用了。
实践里怎么选
倾向 Redux(或 Redux Toolkit)
- 全局状态复杂,模块多,需要 严格 action 日志和中间件链
- 团队已有 Redux 规范、测试和 DevTools 工作流
- 时间旅行调试、action 回放是硬需求
倾向 Zustand
- 中小规模 客户端全局状态,希望少写样板
- 已有 React Query/SWR 管服务端数据,store 只放「会话 + UI」
- 需要 细粒度订阅,又不想维护 Reselect 那套
倾向 Context + useReducer
- 状态域清晰且不大(主题、locale、单一用户信息)
- 更新频率低,能接受 Provider 边界内的渲染范围
倾向 TanStack Query / SWR
- 状态主要来自 API:缓存、去重、失效、后台刷新
- 不要把
{ list, loading, error }再手写进 Redux——重复劳动
可以都不用
- 单页小工具,父子通信足够
- 无跨路由共享,无持久化需求
踩过的坑
坑一:迁移后把什么都塞进全局 store
刚迁到 Zustand 时很爽,结果 Modal 开闭、表格排序、临时筛选 全进了 store,组件间反而耦得更紧。
后来定了一条线:只有跨路由或跨模块共享的才进 store;页面内、抽屉内的 TEMP 状态回 useState。服务端列表坚决不跟 Zustand 混写。
坑二:在 set 外面直接改 state
Zustand 不像 Redux 在 reducer 里强制返回新对象,但 原地修改引用 会让订阅者感知不到变化:
// 错误:mutate 原对象,订阅可能不更新
addItem: (item) => {
get().items.push(item)
}
// 正确:通过 set 返回新引用
addItem: (item) => set((state) => ({
items: [...state.items, item],
}))
Immer 中间件可以写「看起来可变」的代码,但团队要先统一一种风格,别混用。
坑三:selector 拆太碎
为了性能,每个字段单独 useStore(s => s.x),一行组件里挂七八个 hook,订阅注册和比较开销反而上去。
现在的做法:相关字段合并成一个 selector,配合 useShallow(或自己写浅比较),在「少渲染」和「少 hook」之间取平衡。
坑四:把 Redux 里「服务端状态」那套原样搬过来
迁移初期曾在 Zustand 里手写 fetchList、loading、error、lastFetchTime,和 React Query 职责重叠,两处都能改同一份列表,bug 很难查。
拆分后:Query 管 API 缓存,Zustand 只管 user、permissions、uiPreferences。边界清楚,冲突才少。
写在最后
前端状态管理不是在 Redux 和 Zustand 里二选一,而是先问:这份数据是 UI 态、客户端全局态,还是服务端态? 分错桶,换哪个库都难受。
Redux 用纪律换可预测,适合大状态、多写入路径、要强观测的项目;Zustand 用简洁换速度,适合我们这种 全局态不多、又要跨模块共享 的中台。两种都没有银弹。
这次迁移后,样板代码少了很多,新人上手 store 也快。但若业务涨到复杂工作流、要严格 audit 的状态变更,我会先加 RTK 或状态机,而不是假设 Zustand 能一直扛到底。
选型前先画一张「状态地图」:哪些在组件里、哪些在全局、哪些该交给 Query。图画清楚了,库往往自己会浮现。
版权声明: 本文首发于 指尖魔法屋-现代前端状态管理踩坑记录(https://blog.thinkmoon.cn/post/2-state-management-redux-zustand/) 转载或引用必须申明原指尖魔法屋来源及源地址!
评论
使用 GitHub 账号登录后即可留言,支持 Markdown。