面向 Vue3 开发者的 React 快速上手系列
单页里 useState 玩得转,但一旦状态要跨组件、跨路由共享,你就得请出全局状态库。这篇把 Vue 里的 Pinia 心智直接平移到 React 的 Zustand 和 Redux Toolkit,让你选型不纠结、上手不踩坑。
一、先想清楚:到底要不要全局库?
你在 Vue 里也不是每个组件都挂 Pinia store,对吧?React 也一样。局部状态用 useState 就够,它就该待在自己组件里。只有下面这些情况,才值得引入全局状态库:
- 跨组件 / 跨路由共享:比如当前登录用户信息、主题、购物车。props 一层层透传("prop drilling")会让你崩溃。
- 服务端数据缓存:组件卸载再挂载不想重复请求、需要做缓存/失效。这种更推荐 React Query / SWR,但全局 store 也能兜底。
- 需要 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。
防坑
- Zustand 千万别整块取状态。 写
const store = useStore()(不传 selector)会订阅整个 store,任一字段变化都触发你的组件重渲染------和你 Vue 里const { ... } = storeToRefs(store)全量订阅一样的坑。永远按需 selector :useStore(s => s.count)。需要多个值时要么分别 selector,要么用useShallow合并比较。 - Zustand 的
set是浅合并。set({ count: 1 })不会动其他字段,但如果你要更新嵌套对象,记得手动展开或用 immer 中间件:set(state => ({ user: { ...state.user, age: 19 } }))。 - Redux 的不可变 reducer 思想 == React 不可变状态。 别在 reducer 里
return旧的或被改过的 state 引用,否则选择器比对不出来变化、视图不更新。RTK 帮你包了 immer 可以「看起来原地改」,但如果你手写 reducer(比如redux老写法),务必返回新对象。 - 别用全局 store 装「本地一次性状态」。 列表展开、输入框草稿这种只属于某个组件的状态,老老实实用
useState,塞进全局只会让调试变乱、重渲染范围变大。
小结
- 局部状态
useState管够;跨组件/跨路由共享、要 devtools/中间件时才上全局库。 - Zustand = Pinia 的 React 平替 :
create定义、随地 import、set不可变更新、无需 Provider,手感最像。 - Redux Toolkit = 强规范方案 :
createSlice管状态与 reducer、configureStore建 store、<Provider>包裹根、组件useSelector读 +useDispatch写;样板多于 Zustand 但生态最全。 - 选型口诀:中小项目 Zustand,大型规范 Redux Toolkit。
下一篇讲并发特性与性能优化