React 状态管理怎么选?Redux、Zustand、Jotai 有什么区别?

进阶高频对比实践约 10 分钟读完

一句话回答

先分清状态的类型,再选工具:组件自己的 UI 状态用 useState / useReducer;来自接口的 服务端状态交给 TanStack Query、SWR 这类请求库,它们负责缓存、去重和刷新;筛选条件、页码这类需要分享和刷新保留的放进 URL;剩下真正跨组件共享的 客户端全局状态才需要状态库。Redux Toolkit 是集中式 store + action,规范统一、调试工具完善,适合大型团队;Zustand 也是单一 store,但 API 极简,用选择器按需订阅;Jotai 是原子化的,状态拆成一个个 atom,组件只订阅自己用到的 atom。三者都没有 Context 那种"value 一变所有消费者都渲染"的问题。

详细解析

先给状态分类

类型 例子 推荐方案
本地 UI 状态 弹窗开关、输入框内容 useState、useReducer,必要时状态提升
服务端状态 用户信息、列表数据 TanStack Query、SWR,或框架的数据加载
URL 状态 搜索词、筛选条件、页码 路由的查询参数
客户端全局状态 登录态、购物车、主题、多步骤表单 Context、Zustand、Jotai、Redux Toolkit

很多项目里"全局状态很乱",其实是把接口数据手动塞进了全局 store,自己处理 loading、缓存和过期。把服务端状态交给请求库之后,剩下的全局状态通常很少:

JSX
import { useQuery } from '@tanstack/react-query'

function UserCard({ userId }) {
  const { data, isPending, error } = useQuery({
    queryKey: ['user', userId], // 缓存键:userId 变了自动请求新数据
    queryFn: () => fetchUser(userId),
    staleTime: 60_000, // 1 分钟内认为数据是新的,不重复请求
  })
  if (isPending) return <p>加载中</p>
  if (error) return <p>出错了</p>
  return <p>{data.name}</p>
}

Context 为什么不够

Context 只是传递数据的机制,value 变化时所有读取它的组件都会重新渲染,不能只订阅其中一个字段,见 Context 的性能问题。它适合主题、语言这类变化少的数据,频繁变化的共享状态更适合下面这些库。它们把状态放在 React 外部的 store 里,通过 useSyncExternalStore 订阅,组件只在自己选中的数据变化时渲染。

Redux Toolkit

Redux 的核心是单一 store、纯函数 reducer、通过 dispatch action 修改状态,每次变化都可追踪、可回放。Redux Toolkit(RTK)是官方推荐的写法,createSlice 一次生成 reducer 和 action,内置 Immer 可以用"直接修改"的写法产生不可变更新,还自带 RTK Query 处理服务端状态。代价是概念和样板代码比另外两个多:

JSX
const counterSlice = createSlice({
  name: 'counter',
  initialState: { value: 0 },
  reducers: {
    incremented: (state) => {
      state.value += 1 // Immer 会生成新对象
    },
  },
})
const store = configureStore({ reducer: { counter: counterSlice.reducer } })
// 组件中:useSelector((state) => state.counter.value),dispatch(counterSlice.actions.incremented())

Zustand 和 Jotai

  • Zustand:create 直接返回一个 Hook,状态和修改方法写在一起,不需要 Provider,组件外(如请求拦截器中)也能通过 useCartStore.getState() 读写。Vue 生态的对应方案见 Pinia
  • Jotai:自底向上,每个 atom 是一小块状态,派生 atom 由其他 atom 计算得到,依赖变化时自动更新,思路接近 Vue 的 ref 和 computed
JSX
import { atom, useAtomValue, useSetAtom } from 'jotai'

const priceAtom = atom(100)
const qtyAtom = atom(2)
const totalAtom = atom((get) => get(priceAtom) * get(qtyAtom)) // 派生 atom

function Total() {
  return <b>{useAtomValue(totalAtom)}</b> // 只有 total 变化时渲染
}
function AddButton() {
  const setQty = useSetAtom(qtyAtom) // 只写不读,qty 变化时这个组件不渲染
  return <button onClick={() => setQty((q) => q + 1)}>+1</button>
}

对比

Redux Toolkit Zustand Jotai
模型 单一 store,action + reducer 单一 store,直接调用方法修改 分散的 atom
订阅粒度 useSelector 选择器 选择器 按 atom
需要 Provider 需要 不需要 可选
样板代码 较多 很少 很少
适合 大型应用、多人协作、复杂流程 中小型项目、大多数业务场景 状态之间派生关系多、细粒度更新

代码示例:Zustand 购物车

JSX
import { create } from 'zustand'
import { useShallow } from 'zustand/react/shallow'

const useCartStore = create((set) => ({
  items: [],
  coupon: null,
  addItem: (item) => set((state) => ({ items: [...state.items, item] })),
  applyCoupon: (code) => set({ coupon: code }), // set 会浅合并到原状态
}))

function CartTotal() {
  // 选择器返回派生出的原始值,总价不变就不渲染
  const total = useCartStore((state) => state.items.reduce((sum, i) => sum + i.price, 0))
  return <b>{total}</b>
}

function CouponPanel() {
  // 选择器返回新对象时,用 useShallow 做浅比较,否则每次都被认为变了
  const { coupon, applyCoupon } = useCartStore(
    useShallow((state) => ({ coupon: state.coupon, applyCoupon: state.applyCoupon })),
  )
  return <button onClick={() => applyCoupon('VIP')}>{coupon ?? '使用优惠券'}</button>
}

面试官可能追问

为什么说服务端数据不要放进 Redux?

服务端数据是远端数据的一份缓存,要处理加载状态、过期、重新请求、多个组件重复请求时去重、窗口聚焦时刷新、修改后让缓存失效。手动放进 Redux 等于自己实现一遍这些逻辑。TanStack Query、SWR 专门解决这些问题,全局 store 只留真正的客户端状态。

Zustand 的选择器返回对象为什么会导致无限渲染或多余渲染?

Zustand 默认用 Object.is 比较选择器的返回值,(s) => ({ a: s.a, b: s.b }) 每次都返回新对象,永远不相等。v4 中表现为 store 任何变化都会让组件渲染;v5 改为直接使用 React 的 useSyncExternalStore,每次读取快照都不相等,开发环境先警告 getSnapshot 的结果应该缓存,接着报 "Maximum update depth exceeded"。解决方法是分别选取每个字段,或者用 useShallow 包裹选择器做浅比较。

状态库在 SSR 下要注意什么?

模块级的 store 在服务端是所有请求共享的,A 用户的数据可能被渲染给 B 用户。服务端渲染时要为每个请求创建独立的 store,通常是在组件里创建、通过 Context 提供给子组件;Jotai 的 Provider、Redux 的 Provider 都是这个思路。

易错点

  • 一上来就引入全局状态库,结果大部分状态其实是服务端数据或只属于某个组件
  • Zustand 选择器返回新对象、新数组,没有配合 useShallow,按需订阅就失效了
  • 在 Redux 的 reducer 外直接修改 state 对象,或者在没有 Immer 的地方"直接修改",界面不会更新

AI 模拟面试官

用自己的话回答,AI 对照参考答案打分、指出遗漏,再追问,最多 3 轮

登录后就可以和 AI 面试官对练,面试记录也会保存下来。登录

这道题你掌握了吗?

选一个最接近的状态,没掌握的题会出现在"我的进度 · 待复习"里。

学习记录暂存在本机浏览器。登录后自动同步到账号,换设备也能看到。