在 React 组件里写一个计数器,看起来没有任何难度:
jsx
import { useState } from 'react';
function App() {
const [count, setCount] = useState(0);
const addCount = () => {
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
};
return (
<>
<p>当前计数:{count}</p>
<button onClick={addCount}>增加</button>
</>
);
}
export default App;
连续调用了三次 setCount(count + 1),直觉上应该从 0 变成 3。实际点击按钮后,页面却只显示 1。
很多解释会把原因简单归结为"setCount 是异步的"或者"React 只执行了最后一次更新"。这两种说法都不够准确。
真正需要理解的是三件事:
- 每次渲染拿到的是一份状态快照;
setCount提交的是下一次渲染请求,不会修改当前代码里的count;- React 会批量处理同一次事件中的状态更新,并按顺序计算更新队列。
理解这套机制之后,计数器、表单、列表和父子组件中的很多状态问题都会变得清晰。
一、useState 保存的不是普通局部变量
useState 返回一个数组,其中包含当前状态和更新函数:
jsx
const [count, setCount] = useState(0);
可以把它理解为:
text
count 当前这次渲染读取到的状态
setCount 请求 React 使用新状态重新渲染组件
组件第一次运行时,count 是 0。调用 setCount(1) 后,React 会安排一次新的渲染;组件再次运行时,新的 count 才会变成 1。
因此,下面这段代码不会立即打印新值:
jsx
const addCount = () => {
setCount(count + 1);
console.log(count);
};
假设点击前页面显示 0,控制台仍然会打印 0。
原因不是 console.log 跑得比 React 快,而是当前事件处理函数读取到的 count,属于本次渲染的状态快照。setCount 请求下一次渲染,却不会回头修改已经运行中的局部变量。
二、每次渲染都有自己的状态快照
React 函数组件不是只执行一次。状态发生变化后,React 会再次调用组件函数,根据新的状态生成下一版界面。
假设组件当前处于第一次渲染:
text
第一次渲染:count = 0
这一次渲染创建出来的 addCount 事件处理函数,读取的也是这次渲染中的 count:
jsx
const addCount = () => {
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
};
在当前函数里,三次表达式实际都等于:
jsx
setCount(0 + 1);
setCount(0 + 1);
setCount(0 + 1);
也就是连续提交三次"下一次状态替换为 1"。
React 完成处理后重新渲染组件:
text
第二次渲染:count = 1
这就是为什么最终结果是 1。
从 JavaScript 角度看,事件处理函数通过闭包读取了创建它时所在渲染中的 count;从 React 角度看,每次渲染中的状态值都是固定的快照。两种描述并不冲突,但"状态快照"更能解释 React 为什么不会在调用 setter 后立即改变当前变量。
三、React 为什么要批量处理状态更新
React 通常会等当前事件处理函数中的代码执行完成,再统一处理其中的状态更新。这种机制叫作批处理,也就是 Batching。
它类似于在餐厅点单:顾客连续说出多个要求,服务员先记录完整订单,再统一交给后厨,而不是每听到一个词就立即做一道菜。
批处理可以减少没有必要的重复渲染,也能避免界面只更新了一部分状态的"半成品"阶段。
假设一次点击需要同时修改角色的坐标、速度和方向:
jsx
const handleMove = () => {
setX(nextX);
setY(nextY);
setDirection('right');
};
如果每调用一次 setter 都立即完成一次渲染,组件可能连续计算多次。批处理会先收集这些更新,在事件处理完成后计算下一次界面。
需要注意,批处理不等于"React 随便丢掉前面的更新"。React 会把更新放入队列,并按照更新类型和顺序计算最终状态。
四、函数式更新为什么可以连续累加
当下一次状态依赖前一次状态时,应该把更新函数传给 setter:
jsx
const addCount = () => {
setCount(prevCount => prevCount + 1);
setCount(prevCount => prevCount + 1);
setCount(prevCount => prevCount + 1);
};
这次传入的不是三个已经计算好的数字,而是三个更新函数。React 会把它们依次放进队列:
text
初始状态:0
第一个更新函数:0 → 1
第二个更新函数:1 → 2
第三个更新函数:2 → 3
最终状态:3
可以用一张表看清计算过程:
| 队列中的更新 | 接收到的状态 | 返回结果 |
|---|---|---|
n => n + 1 |
0 | 1 |
n => n + 1 |
1 | 2 |
n => n + 1 |
2 | 3 |
函数参数名不一定要写成 prevCount,也可以按照官方文档的写法简化为:
jsx
setCount(c => c + 1);
关键不在名字,而在于这个参数由 React 提供,它表示更新队列计算到当前步骤时的最新待定状态。
五、直接赋值与函数式更新应该怎样选择
并不是所有 setState 都必须写成函数形式。判断标准是:下一次状态是否依赖当前状态。
不依赖当前状态:直接传值
表单输入的新值来自事件对象,并不需要根据原来的输入继续计算:
jsx
const [filterText, setFilterText] = useState('');
<input
value={filterText}
onChange={event => setFilterText(event.target.value)}
/>
此时直接传入 event.target.value 更清楚。
重置状态也适合直接传值:
jsx
setCount(0);
setSelectedUser(null);
setLoading(false);
依赖当前状态:使用更新函数
计数、切换布尔值、在原数组基础上追加数据,都依赖更新前的状态:
jsx
setCount(count => count + 1);
setVisible(visible => !visible);
setUsers(users => [
...users,
newUser,
]);
这样写的价值不只是解决"三次加一"的例子,它还能避免更新发生在批处理、异步回调或连续交互中时读取到旧快照。
六、混合更新时,顺序会影响最终结果
更新队列中的函数和直接替换可以一起出现,但顺序不同,结果也不同。
例如当前 count 为 0:
jsx
setCount(count + 5);
setCount(n => n + 1);
React 先把状态替换成 5,再把 5 交给更新函数,最终结果为 6。
如果交换顺序:
jsx
setCount(n => n + 1);
setCount(count + 5);
第一步先计算出 1,第二步又把状态替换为当前快照计算出的 5,最终结果就是 5。
因此,更准确的理解不是"React 只保留最后一行",而是:
直接传值表示替换状态,函数式写法表示基于队列中的前一个结果继续计算,React 按顺序处理它们。
真实业务中应尽量保持一段连续更新的意图清楚,不要为了炫技混用多种写法。
七、useState 的函数参数还有另一种用途:懒初始化
当前项目中还有一个创建一万条用户数据的例子:
jsx
function heavyComputation() {
const result = [];
for (let i = 0; i < 10000; i++) {
result.push({
id: i,
name: `用户${i}`,
});
}
return result;
}
如果这样初始化:
jsx
const [users] = useState(heavyComputation());
每次组件重新执行时,JavaScript 都会先运行 heavyComputation(),再把计算结果作为参数传给 useState。React 只会在首次渲染使用初始值,但后续渲染中的函数调用成本已经发生了。
更合适的写法是传入初始化函数:
jsx
const [users] = useState(() => heavyComputation());
也可以直接传函数引用:
jsx
const [users] = useState(heavyComputation);
这时 React 会在初始化状态时调用它,后续渲染不会重复执行这段昂贵计算。
这两种"传函数"很容易混淆,但用途不同:
| 使用位置 | 函数用途 |
|---|---|
useState(() => initialValue) |
计算初始状态 |
setCount(count => count + 1) |
根据前一个状态计算下一个状态 |
一个负责初始化,一个负责更新。
八、为什么开发环境里初始化函数可能执行两次
项目入口使用了 StrictMode:
jsx
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
);
在开发环境的严格模式下,React 可能调用初始化函数和更新函数两次,用来帮助开发者发现不纯的逻辑。React 会忽略其中一次结果,这种检查不会影响生产环境。
因此,如果在 heavyComputation 中打印日志,开发环境里看到两次日志,并不代表懒初始化失效。
正确做法是保证初始化函数保持纯净:
- 相同输入产生相同结果;
- 不修改组件外部变量;
- 不发送请求;
- 不写入本地存储;
- 不在其中执行必须且只能发生一次的副作用。
初始化函数应该只负责计算初始状态。请求数据和其他副作用,需要放到更合适的生命周期逻辑中处理。
九、不要在 setCount 后立即读取新状态
下面的写法经常出现在调试代码中:
jsx
setCount(count + 1);
console.log('更新后的 count:', count);
这里打印的仍然是当前渲染快照中的旧值。
如果只是想确认下一次要设置的值,可以先计算:
jsx
const nextCount = count + 1;
setCount(nextCount);
console.log('准备更新为:', nextCount);
如果业务确实需要在状态更新并完成渲染后执行逻辑,可以观察状态变化:
jsx
useEffect(() => {
console.log('count 已更新:', count);
}, [count]);
不过,不要为了读取最新值而滥用 useEffect。能在事件处理中直接根据现有数据计算的结果,就应该直接计算。
十、把 useState 记成一套更新模型
面对状态代码时,可以按下面的顺序判断:
text
当前代码读取的是本次渲染的状态快照
↓
setter 把更新放入队列
↓
事件处理结束后,React 批量处理队列
↓
React 使用最终状态重新执行组件
↓
新一轮渲染获得新的状态快照
因此:
- setter 不会改变当前函数里的状态变量;
- 多次直接传值,可能反复提交同一个替换结果;
- 下一次状态依赖前一次状态时,使用更新函数;
- 昂贵的初始值计算,使用懒初始化函数;
- 更新函数和初始化函数都应保持纯净。
总结
useState 的难点不在 API,而在于它与普通变量的更新方式不同。
React 会为每次渲染提供一份固定的状态快照。调用 setter 只是提交下一次渲染需要处理的更新,不会修改当前事件处理函数已经读取到的状态。React 在事件结束后批量处理更新队列,再使用最终结果重新渲染组件。
所以,连续三次执行:
jsx
setCount(count + 1);
提交的是三次基于同一个快照计算出的 1;而连续三次执行:
jsx
setCount(count => count + 1);
提交的是三个可以沿着更新队列依次计算的函数,最终才会从 0 得到 3。
真正需要记住的不是"setState 是异步的",而是:
状态属于一次渲染的快照,setter 负责提交更新,React 负责按队列计算下一次渲染。