返回博客列表
技术文章React状态管理ContextZustandRedux前端架构

React 状态管理:Context、Zustand 还是 Redux?

2026年05月🇨🇳 中文

有一次接手一个同事的项目,打开代码我愣了半天。

一个 Context 里塞了 20 多个字段,从用户信息到页面筛选条件到弹窗开关到某个表单的临时草稿,全堆在一起。最让人崩溃的是,改一个弹窗的 visible 字段,整个页面 30 多个消费组件全跟着重跑一遍。页面倒不是不能用了,但那种操作滞涩感让你每次交互都觉得不太对——可又说不上来具体哪行代码写错了。

这不是 Context 的问题。Context 的设计本来就不是干这个的。真正的问题是:同一个项目中,不同性质的状态被塞进了同一个容器,而每个容器都有自己的弱点。当你选的容器撞上了它不擅长的状态类型,性能问题就出来了。

所以状态管理方案的选择,从来不是"哪个库更好"这种二维比较。你真正在做的事情是:判断你现在手里的状态会在哪个方案下先出问题,然后选那个不会出问题的。 下面我用一个贯穿全文的场景,把 Context、Zustand 和 Redux 放在一起,看它们在同一个问题前的反应有什么不同。

场景是这样的:一个预约类页面,上面有一个筛选栏(categorydateRangekeyword),左边是服务列表,右边是选中的服务详情和预约按钮,底部有一个多步骤预约流程的进度条。


Context:问题不是"能不能用",是重渲染从什么时候开始让你忍不了

回到那个场景。如果你把所有状态放在一个 Context 里,它大概是这么一个结构:

// 一个典型的"什么都往里塞"的 Context
const BookingContext = createContext(null)

function BookingProvider({ children }) {
  const [category, setCategory] = useState('all')
  const [keyword, setKeyword] = useState('')
  const [dateRange, setDateRange] = useState([null, null])
  const [services, setServices] = useState([])
  const [selectedService, setSelectedService] = useState(null)
  const [currentStep, setCurrentStep] = useState(1)
  const [draftData, setDraftData] = useState({})
  const [submitStatus, setSubmitStatus] = useState('idle')

  return (
    <BookingContext.Provider value={{
      category, setCategory,
      keyword, setKeyword,
      dateRange, setDateRange,
      services, setServices,
      selectedService, setSelectedService,
      currentStep, setCurrentStep,
      draftData, setDraftData,
      submitStatus, setSubmitStatus,
    }}>
      {children}
    </BookingContext.Provider>
  )
}

用户在筛选栏敲一个关键词 keyword,Provider value 变了。然后整个页面里所有 useContext(BookingContext) 的组件全部重渲染——包括底部的进度条、右侧的服务详情卡、甚至一个跟筛选毫无关系的提交状态提示。

这就是 Context 机制的硬伤:它没有 selector。React 没有办法判断"这个组件只依赖 keyword,别的字段变了不要通知它"。Provider 的 value 是一个整体引用,变了就是全量通知。

你可以上手段缓解——把筛选状态拆成一个单独的 Context,预约流程拆成另一个,值不变的 Provider 用 useMemo 稳定引用。这些优化有效,但有一个很实际的代价:你需要持续花精力判断 value 的边界,而且这个精力支出不会因为你熟练了就消失。

所以 Context 真正合适的区间不是"状态简单",而是状态几乎不变。主题切换、语言切换、当前用户的 base info——这些东西一个会话里变不了一两次,30 个消费者一起更新也不构成负担。筛选条件、表单中间态、实时变化的 UI 状态,不应该进 Context。不是因为 Context 不能用,是因为同类问题反复出现时,方案本身就在给你信号了。


Zustand:你的前三个月会非常舒服,第四个月开始有压力

把同样的场景换成 Zustand。Zustand 没有 Provider,没有 Context 嵌套,最关键的是有 selector——你可以精确告诉 React 你要订阅哪个字段,其他字段变了跟你的组件无关:

// 筛选栏组件——只订阅 keyword,category 和 dateRange 变了不会触发这里重渲染
const keyword = useBookingStore(s => s.keyword)
const setKeyword = useBookingStore(s => s.setKeyword)

// 进度条——只订阅 currentStep
const currentStep = useBookingStore(s => s.currentStep)

// 提交按钮——只订阅 submitStatus
const submitStatus = useBookingStore(s => s.submitStatus)

用户敲关键词的时候,只有筛选栏和服务列表重跑。进度条不动,提交按钮不动,服务详情卡不动。这就是 Context 给不了的东西——精确订阅。很多人从 Context 切到 Zustand,理由就在这里。

但 Zustand 有一个代价,刚切过去的前三个月你感觉不到:它不会拦你任何事。

你不小心把接口返回的列表数据写进 store 了?可以。你把本来只在一个页面里用的临时变量放进去了?可以。你同时设了一个全局 store 和一个页面级 store,两边的字段名字还差不多?也可以。Zustand 没有惯例,没有结构约束,没有"A 和 B 应该属于不同 store"的提示。它就是一个自由的 JS 对象。

三个月之后打开一个 Zustand store,你大概率会看到这样的东西堆在一起:

  • usertoken——环境信息,跟 Context 一个性质
  • filterssortBycurrentPage——页面状态,应该进 URL
  • servicesserviceDetail——接口返回的数据,应该归 React Query 管
  • draftDatacurrentStepsubmitStatus——客户端中间态,这些才是 Zustand 该管的东西

每个字段单独看都没错。但合在一起,你没法回答一个最基本的问题:这条数据如果丢了,我应该去接口重新拿,还是应该从用户的操作里重新构建?

Context 的机制太简单(没有 selector),Zustand 的约束太少。它的 selector 帮你解决了"不该重渲染的重渲染了",但它帮不了你更前一步:这个东西该不该放进来?

所以我给自己定了一个规则:服务端数据不进 Zustand。有请求、缓存、失效、重刷这些生命周期的数据,用 React Query 或 SWR 管。Zustand 只管一种东西——客户端自己产生的、多个组件需要共享的中间状态。预约流程的 currentStepdraftData 是典型例子,它们不是从接口拉回来的,是用户在页面上一路操作累积出来的。


Redux:你听到的"太重了"已经过时了,但它仍然只适合一类项目

说到 Redux,先纠正一个过期印象。Redux Toolkit 的 createSlice 把 action type、reducer、初始值都放在一起,和 Zustand 的写法差别没有你想象中大:

const bookingSlice = createSlice({
  name: 'booking',
  initialState: { category: 'all', keyword: '', currentStep: 1, draftData: {}, submitStatus: 'idle' },
  reducers: {
    setKeyword(state, action)    { state.keyword = action.payload },
    nextStep(state)              { state.currentStep += 1 },
    setSubmitStatus(state, action) { state.submitStatus = action.payload },
  }
})

// 组件里
dispatch(setKeyword('react'))
dispatch(nextStep())
dispatch(setSubmitStatus('submitting'))

和 Zustand 的 setKeyword('react') 相比,多了一个 dispatch() 包裹。这层包裹看起来只是多打几个字,但它的真正意义不是语法——是每一次状态变化都被命名了,而且是有序的

还是回到预约场景。假设你的预约流程出了一个问题:用户说提交失败了,但拒绝告诉你他当时点了什么。在 Zustand 里,你只能检查 submitStatus 的当前值,不知道它是从 idlesubmittingerror 的,还是从 submitting 直接跳到 error 的,甚至可能根本没有进入过 submitting

Redux DevTools 给你看的是完整的事件时间线:

12:03:01  booking/setKeyword          'react'
12:03:04  booking/setCategory          'consulting'
12:03:08  booking/nextStep             1 → 2
12:03:15  booking/setSubmitStatus      'submitting'
12:03:18  booking/setSubmitStatus      'error'    ← 这里出问题了

这不是"多一个工具面板",而是你排障时不用再猜"到底发生了什么"。对于单人或两三个人的小团队,这个能力可能用不上——你改动范围小,出 bug 时上下文还在自己脑子里。但对于 5 人以上的团队,或者状态变化路径超过 3 条的业务逻辑,能按 action 回溯状态的每一次变化,就开始值回它那层 dispatch 的成本了。

RTK Query 又往前推了一步:它把服务端数据的请求、缓存、失效、乐观更新全部化成一套约定,你不用再手写"请求前设 loading → 请求完解析 → 出错设 error"。这一点和 React Query 是同类的能力,区别只是它跟 Redux 的状态树是同一套体系。

所以 Redux 不是"大项目就用"。更准确的说法是:当"状态变化的可追溯性"带来的安全感,超过了"每次改动都要写 action 名字"带来的麻烦感,那 Redux 的代价就值。 订单流、审批流、多步骤工作流、多人实时协作——这些场景天然需要可追溯。内部的简单管理后台、个人项目、一次性活动页——不值得。


同一个预约场景,三个方案的代价分布

回到最开始那个预约页面。把这三种方案放在同一个场景下,它们的代价分布就能看清楚了:

ContextZustandRedux
筛选敲字时,进度条会重渲染吗会(除非你手动拆 Provider)不会(selector 精确订阅)不会(selector 精确订阅)
需要提前设计状态结构吗需要(拆 Provider 的决策)不需要(但后续拆 store 代价高)需要(slice 的划分)
三个月后新同事能看懂吗能(如果 Provider 拆得干净)不一定(完全取决于当时人怎么放的)大概率能(action 是自解释的)
出 bug 时能回溯状态变化吗不能不能(除非额外接工具)能(DevTools 时间旅行)
适合你的预约场景吗不适合高频筛选适合,但要注意服务端数据不入 store适合,如果团队 5 人以上或业务规则复杂

这张表不是让你直接套结论,而是回到最开始那句话:你选方案,其实是在选你愿意把复杂度放在哪个阶段。


选哪个

选 Context,你要接受的代价是自己管理重渲染边界。这包括持续关注 value 结构、拆 Provider、给消费组件加 memo。如果状态更新频率够低,这些代价接近于零。

选 Zustand,你要接受的代价是自己维护分类约定。哪些进 store、哪些不进、哪些该拆到独立 store,这些判断没有工具替你回答。你越自律,Zustand 就越稳定。你越放松,它就越像当年的 Context——什么都有,什么都在一块儿。

选 Redux,你要接受的代价是每次状态变化都要走 action 这个门。这个成本在小项目里是净损失。但当项目规模和团队人数超过某个阈值后,这笔成本开始变成资产——因为你不再需要靠记忆或口口相传去回答"那个字段到底是谁改的"。

这么看,一个项目里同时存在 Context 和 Zustand,或者 Context 和 Redux,完全不奇怪。Context 管主题和用户 base info,Zustand 管预约流程中间态,React Query 管服务端数据——它们管的是不同类型的状态,硬用同一把工具反而会让复杂度往不该去的地方渗透。