状态管理第一步:React useState 核心行为完全拆解

前言

如果你刚接触 React,大概率第一个遇到的 Hook 就是 useState。本文会用一个 Vite + React 项目中的两个小示例,一步步拆解它的工作机制。我们不求面面俱到,只求把最核心的几个概念讲清楚。

本文涉及的代码文件:

  • src/App.jsx --- 用户列表 + 筛选,演示惰性初始化派生状态
  • src/App2.jsx --- 计数器,演示异步更新函数式更新

一、useState 能做什么

简单说,useState 让你在函数组件里拥有"会变的数据"。一个计数器可能是这样:

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

count 是当前值,setCount 是用来改值的函数。改值之后,React 会重新跑一次组件函数,页面就会跟着更新。

传统的变量是做不到这一点的------你改了值,页面不会自动刷新。而 useState 搭起了"数据变化 → 界面重渲染"这座桥。


二、状态的变更其实是"排队的"

来看 App2.jsx 里的这段代码:

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

const addCount = () => {
  setCount(count + 1)
  console.log(count)   // 这里打印的是旧值还是新值?
}

很多初学者直觉上会觉得 console.log(count) 应该打印 1。但实际上,它打印的是 0。

原因在于,setCount 不会立刻修改 count 它做的事情更像是:"嘿 React,我这儿有个更新,你找个合适的时间处理一下。" 然后当前这段代码继续往下跑,等整段跑完之后,React 才会统一处理排队的更新,并触发一次重新渲染。

可以这样理解执行顺序:

scss 复制代码
用户点击按钮
  ↓
setCount(count + 1)    → 把更新加入队列,count 暂时不变
  ↓
console.log(count)     → 同步代码,拿到的是尚未更新之前的 0
  ↓
当前事件处理函数执行完毕
  ↓
React 处理队列中的更新,组件函数重新执行
  ↓
count 变成 1,页面更新

这个机制通常被称为批量更新(batching)------React 会把短时间内多次 setState 合并处理,免得每改一个值就渲染一次,浪费性能。


三、连续多次 setState 的小陷阱

假设你想点一次按钮就让计数器加 3:

jsx 复制代码
setCount(count + 1)  // count 当前是 0
setCount(count + 1)  // count 当前是 0(还没更新)
setCount(count + 1)  // count 当前是 0(还没更新)

三次调用拿到的 count 都是 0,React 批量处理后,最终 count 变成了 1 而不是 3。

这不是 bug,而是 React 有意为之的优化策略。同一个事件处理函数中的多次 setState,React 会尽量合并。

那如果想实现 +3 的效果呢?这时候就该用函数式更新了:

jsx 复制代码
setCount(prev => prev + 1)  // prev = 0 → 返回 1
setCount(prev => prev + 1)  // prev = 1 → 返回 2
setCount(prev => prev + 1)  // prev = 2 → 返回 3

当你传给 setCount 的不是一个值,而是一个函数 时,React 会把这个函数放进一个队列里,然后按顺序执行。每一步的 prev 都是上一步的返回值,所以最终能拿到正确累积的结果。

什么时候用函数式更新? 一个简单的判断标准:如果你的新状态依赖旧状态,用函数式更新通常更安全。它不依赖闭包里的旧快照,而是总是拿到最新的前一次结果。


四、初始值很"贵"的时候怎么办

App.jsx 里有一个模拟"昂贵计算"的函数:

jsx 复制代码
function heavyComputation() {
  console.log('开始执行 heavyComputation....')
  const startTime = performance.now()
  const result = []
  for (let i = 0; i < 10000; i++) {
    result.push({ id: i, name: `用户:${i}` })
  }
  console.log(`heavyComputation 执行耗时: ${performance.now() - startTime}ms`)
  return result
}

假设初始状态需要跑这样一段比较耗时的逻辑,比如生成大量用户数据。两种写法看起来很像,但效果截然不同:

jsx 复制代码
// 写法 A
const [users] = useState(heavyComputation())

// 写法 B
const [users] = useState(() => heavyComputation())

写法 AheavyComputation() 会在每次渲染时都执行。虽然 React 后续渲染时会忽略返回值(只取第一次的),但函数本身跑了,时间就花了。

写法 B :传入的是函数引用 () => heavyComputation(),React 只在组件首次挂载时调用它。后续重新渲染时,React 看到传进来的是函数,直接跳过。

这就是 lazy initialization(惰性初始化)。当初始值需要经过复杂计算才能得到时,传入一个函数而不是计算结果,可以避免不必要的开销。

当然,如果初始值只是一个简单的 0[],就没必要纠结了------直接传值完全没问题。


五、不是所有的数据都需要变成 state

App.jsx 里还有一个值得注意的细节:

jsx 复制代码
const [users] = useState(() => heavyComputation())
const [filterText, setFilterText] = useState('')

// 注意:filteredUsers 不是 state
const filteredUsers = users.filter(user =>
  user.name.includes(filterText)
)

usersfilterText 是 state,而 filteredUsers 只是一个普通变量------它由 usersfilterText 计算得来。

这是一个很有用的思维方式:能推导出来的值,不需要单独存一份 state。 每次组件重新渲染时,filteredUsers 会自然地根据最新的 usersfilterText 重新计算。

反过来,如果把它也设成 state:

jsx 复制代码
const [filteredUsers, setFilteredUsers] = useState(users)

那就需要手动在 usersfilterText 变化时同步更新它,凭空多了一份维护成本和出错的可能性。

这类值在 React 社区里常被称为 computed / derived state(派生状态)。它不是一个正式 API,而是一种设计思路:state 应该是"最少必要的那几个",剩下的交给计算。


六、换一个角度理解 state

抛开 API 细节,可以这样理解 React 组件:

ini 复制代码
组件 = 一个函数
渲染 = 运行这个函数
state = 函数里会变、但变化时能让函数重新运行的变量

每一次渲染都是一次独立的函数调用,函数内部的变量形成一次"快照"。setState 做的事情本质上是告诉 React:"这轮跑完之后,用新值再跑一次。"

带着这个视角去看:

  • 为什么 console.log(count) 拿到的是旧值? 因为当前这次函数调用还没结束,快照还是旧的。
  • 为什么函数式更新能突破限制? 因为 prev => prev + 1 不依赖闭包里的快照,它读的是 React 内部管理的最新值。
  • 为什么惰性初始化有用? 因为函数每次都会重新执行,但我们希望某些昂贵的逻辑只在"第一次"跑。

七、小结

要点 一句话
useState 的作用 让函数组件拥有会触发重渲染的数据
setState 的异步性 更新是排队的,当前代码块内读不到新值
批量合并 同一事件中多次 setState 会合并处理
函数式更新 prev => newValue 用来安全地依赖旧值
惰性初始化 初始计算很贵时,传函数比传值更节省
派生状态 能推导的值不要设为 state,减少冗余

这篇文章聚焦在 useState 最核心的几个行为上。如果你还想了解它在真实项目里怎么和其他组件配合使用(状态提升、父子通信等),可以参考同目录下 todolist 项目的代码和笔记。

相关推荐
拾年2755 小时前
React 组件通信完全指南 —— 从 Todo App 搞懂父子组件那点事
react.js
先吃饱再说5 小时前
React 组件通信:从 Props 单向传递到自定义事件
前端·react.js·前端框架
何时梦醒6 小时前
⚛️ React 19 组件化实战 —— 从零搭建 Todo List 并吃透组件通信
前端·人工智能·react.js
bonechips6 小时前
React 组件通信:一套 TodoList 讲清 props 与回调
前端·react.js
不好听6138 小时前
React 父子组件通信:从 Todo 应用理解单向数据流
前端·react.js
光影少年8 小时前
RN 路由栈管理、页面销毁、返回拦截
javascript·react native·react.js
小林ixn18 小时前
从零到一理解 React 父子组件通信:手写一个 Todo 应用带你彻底搞懂单向数据流
前端·javascript·react.js