从 Vue3 到 React · 快速上手系列第 6 篇:状态管理 useState 与 useReducer

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

这是整个系列最核心的一篇。Vue 的响应式(ref / reactive)是它的灵魂,React 对应的就是 useState。但两者背后的机制截然不同------理解这个差异,你就真正"懂" React 了。


一、useState vs ref:第一眼

js 复制代码
// Vue3
const count = ref(0)
count.value++              // 直接改,视图自动更新
jsx 复制代码
// React
const [count, setCount] = useState(0)
setCount(count + 1)        // 调用 setter,请求重新渲染

useState(初始值) 返回一个数组:[当前值, 更新函数]。这里用了 JS 的数组解构,你可以随意命名,约定是 [x, setX]。

核心差异:

  • Vue:count 是个响应式容器,你改 .value,Vue 精准更新用到它的地方。
  • React:count 就是本次渲染的一个普通数值快照 ;调用 setCount 不是"改变量",而是告诉 React"用新值重新渲染整个组件"。

二、为什么必须用 setter,不能直接改?

回顾第 1 篇的核心:React 组件每次渲染都重新执行整个函数。普通变量每次都会被重置,所以 React 需要把状态存在自己内部(Fiber 里),每次渲染时通过 useState 把最新值"发"给你。

jsx 复制代码
function Counter() {
  const [count, setCount] = useState(0)

  // ❌ 直接改普通思维会失效
  const bad = () => { count = count + 1 }  // 视图不会更新

  // ✅ 必须通过 setter 请求渲染
  const good = () => setCount(count + 1)

  return <button onClick={good}>{count}</button>
}

三、state 是"快照",不是"实时值"

Vue 里改完 count.value 立刻就能读到新值。React 里,在同一次渲染中,state 永远是那次渲染时的值,不会中途变化:

jsx 复制代码
function Demo() {
  const [count, setCount] = useState(0)

  const handleClick = () => {
    setCount(count + 1)
    console.log(count)  // 打印的还是旧值!新值下次渲染才生效
  }
  // ...
}

更经典的坑------连续调用多次:

jsx 复制代码
const handleClick = () => {
  setCount(count + 1)  // count 是 0,请求渲染为 1
  setCount(count + 1)  // count 还是 0,又请求渲染为 1
  setCount(count + 1)  // 结果只 +1,而不是 +3!
}

解决办法:用"函数式更新",参数是"上一个最新值":

jsx 复制代码
const handleClick = () => {
  setCount(prev => prev + 1)   // 0 -> 1
  setCount(prev => prev + 1)   // 1 -> 2
  setCount(prev => prev + 1)   // 2 -> 3  ✅
}

经验法则:只要新值依赖旧值,就用 setCount(prev => ...) 的函数式写法,能规避绝大多数"拿到旧值"的 bug。


四、对象与数组:不可变更新(Vue 开发者最不适应的点)

Vue 的 reactive 对象可以随便原地改:

js 复制代码
// Vue3
const user = reactive({ name: 'Tom', age: 20 })
user.age = 21               // 直接改,OK
const list = reactive([1, 2])
list.push(3)                // 直接 push,OK

React 的 state 必须创建新对象/新数组替换,不能原地修改:

jsx 复制代码
const [user, setUser] = useState({ name: 'Tom', age: 20 })

// ❌ 原地改,React 察觉不到(引用没变)
user.age = 21; setUser(user)

// ✅ 用展开运算符创建新对象
setUser({ ...user, age: 21 })

数组同理,用返回新数组的方法(map/filter/concat/展开),别用 push/splice 这类原地修改:

jsx 复制代码
const [list, setList] = useState([1, 2, 3])

setList([...list, 4])                       // 新增
setList(list.filter(n => n !== 2))          // 删除
setList(list.map(n => n === 2 ? 20 : n))    // 修改

为什么? React 靠"引用是否变化"来判断要不要重渲染。原地修改引用没变,React 以为没变化。这条"不可变原则"是 React 的铁律。

💡 嫌手动展开麻烦?大型嵌套对象可以用 Immer(useImmer),让你像 Vue 一样"直接改",它在底层帮你生成新对象。这是很多从 Vue 转来的人的救星。


五、多个状态:拆开还是合并?

Vue 常把相关数据放一个 reactive 对象。React 里两种都行,原则是:

jsx 复制代码
// 独立、无关的状态 → 拆开(推荐)
const [name, setName] = useState('')
const [age, setAge] = useState(0)
const [loading, setLoading] = useState(false)

// 强相关、总是一起更新的 → 合并成一个对象
const [form, setForm] = useState({ name: '', email: '', phone: '' })
setForm(prev => ({ ...prev, name: 'Alice' }))

六、惰性初始化:昂贵的初始值只算一次

如果初始值需要昂贵计算(比如读 localStorage),传一个函数 给 useState,它只在首次渲染执行一次:

jsx 复制代码
// ❌ 每次渲染都执行 expensiveInit()
const [data, setData] = useState(expensiveInit())

// ✅ 只在首次渲染执行一次
const [data, setData] = useState(() => {
  const saved = localStorage.getItem('data')
  return saved ? JSON.parse(saved) : defaultData
})

这个坑正是因为"组件每次渲染都重跑整个函数"导致的------牢记第 1 篇的核心。


七、useReducer:复杂状态逻辑的利器

当一个状态有多种更新方式、逻辑复杂时(比如一个表单、一个购物车),useState 会散落一堆 setter。这时用 useReducer,把所有更新逻辑集中到一个 reducer 函数里。它类似于把 Pinia 的 actions 收拢在一起的思路:

jsx 复制代码
import { useReducer } from 'react'

// reducer:根据 action 返回新 state(注意仍然是不可变更新)
function reducer(state, action) {
  switch (action.type) {
    case 'increment': return { count: state.count + 1 }
    case 'decrement': return { count: state.count - 1 }
    case 'set':       return { count: action.payload }
    default:          return state
  }
}

function Counter() {
  const [state, dispatch] = useReducer(reducer, { count: 0 })

  return (
    <div>
      <span>{state.count}</span>
      <button onClick={() => dispatch({ type: 'increment' })}>+</button>
      <button onClick={() => dispatch({ type: 'decrement' })}>-</button>
      <button onClick={() => dispatch({ type: 'set', payload: 0 })}>清零</button>
    </div>
  )
}

选择建议:

  • 简单、独立的值 → useState
  • 多字段、多操作、更新逻辑复杂 → useReducer
  • 跨组件的全局状态 → Context 或 Zustand/Redux(第 11 篇讲)

八、对照速查

需求 Vue3 React
基本类型状态 ref(0) useState(0)
读取值 count.value count(直接用)
修改值 count.value++ setCount(c => c + 1)
对象状态 reactive({}) useState({}) + 展开更新
修改对象属性 obj.a = 1 setObj({...obj, a: 1})
数组增删 arr.push() setArr([...arr, x])
复杂状态逻辑 reactive + 方法 useReducer

小结

  • useState 返回 [值, setter],改状态只能调 setter(请求渲染),不能直接赋值。
  • state 是快照 :同一次渲染内不会变;新值依赖旧值时用 setX(prev => ...)。
  • 不可变更新是铁律:对象/数组要创建新的替换,嫌麻烦可用 Immer。
  • 惰性初始化 useState(() => ...) 处理昂贵初始值。
  • 复杂状态逻辑用 useReducer 集中管理。

下一篇讲副作用与生命周期 ------ watch/watchEffect/onMounted 如何翻译为 useEffect ------它一个 Hook 就统一了你熟悉的 watch、watchEffect、onMounted、onUnmounted。

相关推荐
卸任5 小时前
Vibe coding工具使用心得
前端·ai编程
brien96 小时前
为什么模型标称 1M,Google Antigravity 聊到 256K 却突然失忆?我们给它做了个原生行内监控圆环
前端
修炼的dance6 小时前
录屏关键帧间隔多少合适?六档实测,2秒到10秒小了75.6%
前端
Lank_M7 小时前
关键帧从1秒拉到10秒,录屏小87.3%,摄像头小10.8%
前端
补码缩写7 小时前
用 ffprobe 拆录屏,八成字节在 29 个关键帧上
前端
百思不得小李7 小时前
一个复杂的动态表单是如何实现的
前端·设计模式
陈随易7 小时前
Cmder 停更 17 个月,2.0 支持 Windows Terminal
前端·后端·程序员
风骏时光牛马7 小时前
前沿视界
前端
kyriewen7 小时前
我扫了 814 个依赖,97 个还敢要 Node 10
前端·javascript·程序员
陈随易7 小时前
3000 万人在用的翻译插件,重写一遍就是一门生意
前端·后端·程序员