现代前端状态管理踩坑记录

登录态在 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,才会觉得「太重」。状态管理选型,首先是分桶,不是先选库。

graph LR A[数据从哪来?] --> B[组件内部] A --> C[多组件共享] A --> D[服务端 API] B --> B1[useState / useReducer] C --> C1[Context / Zustand / Redux] D --> D1[TanStack Query / SWR / RTK Query]

什么场景绕不过去

下面这些情况,单靠组件内 useState 通常会很快撞墙——不是不能硬写,而是维护成本会指数上去:

场景为什么需要集中管理常见做法
登录态 / 权限全站几十处要读,登出时要一次性清空Context、Zustand、Redux
购物车 / 草稿箱跨路由保留,刷新后还要恢复全局 store + persist
复杂表单 / 向导多步之间共享字段,中间步骤只读部分数据useReducer、Zustand、Form 库
实时协作 / 通知WebSocket 推来的消息要驱动多个面板全局 store 或事件总线
后台列表 + 详情联动列表筛选项、选中行、详情缓存要同步服务端状态库 + 少量全局 UI 状态

不得不上全局状态管理,通常满足其中两条:

  1. 同一份数据被 3 个以上互不相关的组件树读写——再 props 传递就是人为制造耦合。
  2. 写入逻辑有业务规则——例如登出清缓存、加购时校验库存,需要单一入口而不是到处 setState
  3. 你需要可观测性——时间旅行调试、埋点、回放用户操作路径(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 比,差异不在「能不能管全局状态」,而在 默认约定的严格程度

维度ReduxZustand
改状态的入口必须 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 里手写 fetchListloadingerrorlastFetchTime,和 React Query 职责重叠,两处都能改同一份列表,bug 很难查

拆分后:Query 管 API 缓存,Zustand 只管 userpermissionsuiPreferences。边界清楚,冲突才少。

写在最后

前端状态管理不是在 Redux 和 Zustand 里二选一,而是先问:这份数据是 UI 态、客户端全局态,还是服务端态? 分错桶,换哪个库都难受。

Redux 用纪律换可预测,适合大状态、多写入路径、要强观测的项目;Zustand 用简洁换速度,适合我们这种 全局态不多、又要跨模块共享 的中台。两种都没有银弹。

这次迁移后,样板代码少了很多,新人上手 store 也快。但若业务涨到复杂工作流、要严格 audit 的状态变更,我会先加 RTK 或状态机,而不是假设 Zustand 能一直扛到底。


选型前先画一张「状态地图」:哪些在组件里、哪些在全局、哪些该交给 Query。图画清楚了,库往往自己会浮现。

版权声明: 本文首发于 指尖魔法屋-现代前端状态管理踩坑记录https://blog.thinkmoon.cn/post/2-state-management-redux-zustand/) 转载或引用必须申明原指尖魔法屋来源及源地址!