从 Vue3 到 React · 快速上手系列第 11 篇:状态管理

面向 Vue3 开发者的 React 快速上手系列

单页里 useState 玩得转,但一旦状态要跨组件、跨路由共享,你就得请出全局状态库。这篇把 Vue 里的 Pinia 心智直接平移到 React 的 Zustand 和 Redux Toolkit,让你选型不纠结、上手不踩坑。


一、先想清楚:到底要不要全局库?

你在 Vue 里也不是每个组件都挂 Pinia store,对吧?React 也一样。局部状态用 useState 就够,它就该待在自己组件里。只有下面这些情况,才值得引入全局状态库:

  1. 跨组件 / 跨路由共享:比如当前登录用户信息、主题、购物车。props 一层层透传("prop drilling")会让你崩溃。
  2. 服务端数据缓存:组件卸载再挂载不想重复请求、需要做缓存/失效。这种更推荐 React Query / SWR,但全局 store 也能兜底。
  3. 需要 devtools / 中间件 / 时间旅行调试 :想看状态变更历史、做持久化(persist)、日志等。

对照 Vue:你在 Vue 项目里引入 Pinia,也是因为 provide/inject 太散、传太深,需要一个有结构、有 devtools、能模块化拆分的 store。React 的全局库干的完全是一回事------只是 React 连「响应式」都没有,所以这件事更显眼。

一句话:useState 解决的别上全局库;真要共享了,再按下面三选一。


二、Pinia ↔ Zustand(重点,手感最像)

Zustand 是 React 生态里和 Pinia 最像的那个:一个 create 定义 store,想在哪用就在哪 import,不用 Provider 包裹 。这对从 Vue 过来的人是巨大解脱------Pinia 的 useStore() 随地调用,Zustand 几乎一模一样。

Vue3 + Pinia

vue 复制代码
<!-- stores/counter.ts -->
import { defineStore } from 'pinia'
import { ref } from 'vue'

export const useCounterStore = defineStore('counter', () => {
  const count = ref(0)
  const inc = () => count.value++

  // getter 也可以
  const double = computed(() => count.value * 2)

  return { count, inc, double }
})
vue 复制代码
<!-- 组件里使用 -->
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useCounterStore } from './stores/counter'

const store = useCounterStore()
// 解构需保持响应性,必须用 storeToRefs
const { count, double } = storeToRefs(store)
const { inc } = store   // 方法可以直接解构
</script>

<template>
  <button @click="inc">点了 {{ count }} 次,双倍 {{ double }}</button>
</template>

React + Zustand(React 19,函数组件 + Hooks)

tsx 复制代码
// stores/counter.ts
import { create } from 'zustand'

export const useCounterStore = create((set) => ({
  count: 0,
  double: 0,            // 派生值可以手动维护,或组件内用 useMemo
  inc: () => set((s) => ({ count: s.count + 1 })),
}))
tsx 复制代码
// 组件里使用 ------ 注意:没有 Provider,直接 import 用
import { useCounterStore } from './stores/counter'

function Counter() {
  // 按需 selector:只订阅 count 这一项
  const count = useCounterStore((s) => s.count)
  const inc = useCounterStore((s) => s.inc)

  return <button onClick={inc}>点了 {count} 次</button>
}

关键点对比:

  • Pinia 用 ref(0) 声明状态,Zustand 用普通初始值 count: 0
  • Pinia 改状态是 count.value++(响应式自动追踪);Zustand 改状态必须走 set,并且set((s) => ({...})) 的不可变写法------你是在返回「新的一部分状态」,不是原地改。这正好呼应第 3 篇「状态不可变」。
  • 组件取状态:Pinia 用 storeToRefs 保响应性;Zustand 直接 useStore(selector)Zustand 不需要任何 Provider 包裹根组件,这是它比 Redux 省事、最接近 Pinia「随地 import 直接用」的精髓。

三、Pinia ↔ Redux Toolkit(经典方案)

Redux Toolkit(RTK)是官方钦定的现代 Redux 写法。它比老 Redux 简洁得多(createSlice 自动生成 action、内置 immer),但样板代码仍明显多于 Zustand。它要 Provider 包裹根组件 ,这点更像 Vue 里你得先 app.use(pinia)

Vue3 + Pinia(回看,作为参照)

上面 Pinia 那段已经是参照。Pinia 的 store 也是先 app.use(createPinia()) 再全局可用------但你在业务组件里依然「随地 import 直接用」,没有逐层 Provider。

React + Redux Toolkit

tsx 复制代码
// stores/counterSlice.ts
import { createSlice } from '@reduxjs/toolkit'

const counterSlice = createSlice({
  name: 'counter',
  initialState: { count: 0 },
  reducers: {
    // state 这里虽看起来像直接改,但 RTK 内置 immer,实际是安全的不可变更新
    inc: (state) => {
      state.count += 1
    },
  },
})

export const { inc } = counterSlice.actions
export default counterSlice.reducer
tsx 复制代码
// stores/store.ts
import { configureStore } from '@reduxjs/toolkit'
import counterReducer from './counterSlice'

export const store = configureStore({
  reducer: { counter: counterReducer },
})
tsx 复制代码
// main.tsx ------ 必须用 Provider 包裹根组件
import { Provider } from 'react-redux'
import { store } from './stores/store'
import App from './App'

createRoot(document.getElementById('root')!).render(
  <Provider store={store}>
    <App />
  </Provider>
)
tsx 复制代码
// 组件里使用
import { useSelector, useDispatch } from 'react-redux'
import { inc } from './stores/counterSlice'

function Counter() {
  const count = useSelector((state) => state.counter.count)  // 读
  const dispatch = useDispatch()                            // 写

  return <button onClick={() => dispatch(inc())}>点了 {count} 次</button>
}

读 vs 写:

  • 读:useSelector((state) => state.counter.count),本质上就是一个 selector 函数从全局 state 里挑你要的片段。
  • 写:useDispatch() 拿到 dispatch,再 dispatch(inc()) 触发 reducer。inc() 是由 createSlice 自动生成的 action creator。
  • RTK 的 reducer 里你写的 state.count += 1 看似「原地改」,其实 RTK 内置了 immer,背后还是不可变更新------这点和 React 的「不可变」哲学完全一致,不用担心。

四、三者对照表

维度 Pinia (Vue) Zustand Redux Toolkit
创建 store defineStore('name', setup) create((set) => ({...})) createSlice + configureStore
读状态 store.count / storeToRefs useStore(s => s.x) useSelector(s => s.x)
改状态 直接 count.value++ set(s => ({...})) dispatch(action) → reducer
是否需 Provider 否(app.use(pinia) 一次即可) ,随地 import <Provider store> 包裹根
是否不可变 否(响应式,可原地改) 是(set 返回新片段) 是(reducer 不可变)
生态 / 体量 Vue 官方,轻 极轻,依赖少 庞大,中间件/生态最全
适合场景 Vue 项目默认选 个人 / 中小项目优先 大型团队 / 强规范 / 复杂状态流

选型建议:

  • 个人项目、中小团队、想快速上手 → 优先 Zustand。 样板最少、无需 Provider、心智模型最贴近 Pinia,几乎零负担。
  • 大型团队、强流程规范、需要丰富中间件/生态/严格可预测的状态流 → 选 Redux Toolkit。 它的「action 显式派发 + reducer 集中管理」天然适合多人协作和代码审查,但代价是更多样板。
  • 服务端数据缓存(而非纯客户端状态)需求重的话,再叠加 React Query / SWR,别把所有东西都塞进全局 store。

防坑

  1. Zustand 千万别整块取状态。const store = useStore()(不传 selector)会订阅整个 store,任一字段变化都触发你的组件重渲染------和你 Vue 里 const { ... } = storeToRefs(store) 全量订阅一样的坑。永远按需 selectoruseStore(s => s.count)。需要多个值时要么分别 selector,要么用 useShallow 合并比较。
  2. Zustand 的 set 是浅合并。 set({ count: 1 }) 不会动其他字段,但如果你要更新嵌套对象,记得手动展开或用 immer 中间件:set(state => ({ user: { ...state.user, age: 19 } }))
  3. Redux 的不可变 reducer 思想 == React 不可变状态。 别在 reducer 里 return 旧的或被改过的 state 引用,否则选择器比对不出来变化、视图不更新。RTK 帮你包了 immer 可以「看起来原地改」,但如果你手写 reducer(比如 redux 老写法),务必返回新对象。
  4. 别用全局 store 装「本地一次性状态」。 列表展开、输入框草稿这种只属于某个组件的状态,老老实实用 useState,塞进全局只会让调试变乱、重渲染范围变大。

小结

  • 局部状态 useState 管够;跨组件/跨路由共享、要 devtools/中间件时才上全局库。
  • Zustand = Pinia 的 React 平替create 定义、随地 import、set 不可变更新、无需 Provider,手感最像。
  • Redux Toolkit = 强规范方案createSlice 管状态与 reducer、configureStore 建 store、<Provider> 包裹根、组件 useSelector 读 + useDispatch 写;样板多于 Zustand 但生态最全。
  • 选型口诀:中小项目 Zustand,大型规范 Redux Toolkit。

下一篇讲并发特性与性能优化

相关推荐
GuWenyue1 小时前
90%前端写React TS都踩坑!一套父子组件+Hooks完整实战,彻底搞懂类型约束
前端·react.js
Hilaku1 小时前
当 AI 一天写完一周的代码,我们还剩什么优势?
前端·javascript·程序员
尘世中一位迷途小书童1 小时前
无人机航线里的云台角度怎么调?Cesium 做了个视锥 + 鹰眼联动
前端·javascript·数据可视化
张元清1 小时前
React useMeasure Hook:用 ResizeObserver 测量 DOM 元素 (2026)
javascript·react.js
思码梁田2 小时前
CSS display 属性:从元素类型转换到隐藏元素的实用指南
前端·css·display·inline·block·none·inline-block
Csvn2 小时前
⚡ Vite 依赖预构建(optimizeDeps)的 3 个经典坑:改了源码不生效、新增依赖 404、缓存怎么清都不行
前端
A24207349302 小时前
Vue3 + TypeScript:后端数据在表格内渲染后进行增删改的完整实现步骤
前端·javascript·typescript
他们叫我 悦儿遥遥雨2 小时前
.NET 8 Web开发入门(六):Blazor 全栈开发——告别 JavaScript 焦虑
前端·javascript·.net
用户2181697049302 小时前
Flutter(九)StatelessWidget StatefullWidget
前端