一句话定义
State 是组件的私有记忆:const [value, setValue] = useState(初值) 返回一对「本次渲染的快照值」和「触发下次渲染的 setter」;setter 之后界面的更新发生在下一轮渲染,而不是立即。
为什么重要
State 是让界面「活起来」的唯一入口(交互驱动)。同时它也是初学者 bug 密度最高的地方——几乎全部源于一个理解偏差:「render 里的变量是快照,不是活引用」。本篇把快照模型一次讲透。
前置知识
- kp-001 UI = f(state)。
- 数组解构。
核心概念
- 快照(Snapshot):每次渲染时,
count的值都是那一次渲染的定格照片。 - 状态批量更新(Batching):一次事件里的多次 setState 合并为一次重渲染(React 18 起所有上下文均批量)。
- 函数式更新:
setCount(c => c + 1)基于「排队时的最新值」计算,适合连续多次更新。 - 状态不可变(Immutable Update):更新对象/数组时创建新引用,不修改旧值。
- State 隔离:同一组件渲染两次(两处实例),state 互不相干。
原理 / 机制
经典「三次点击等于 1」之谜:
jsxfunction handleClick() {
setCount(count + 1); // 用的是本次渲染的快照 count
setCount(count + 1); // 还是同一个快照 → 还是 count+1
setCount(count + 1); // 排队三次相同请求 → 最终 +1
}
渲染期间 count 是 const 快照,三行代码读到的是同一个数字。修正:
jsxfunction handleClick() {
setCount(c => c + 1);
setCount(c => c + 1);
setCount(c => c + 1); // 排队三个"基于上一次结果"的更新 → +3
}
不可变更新对照表:
| 类型 | ❌ 变异(不触发正确更新) | ✅ 不可变 |
|---|---|---|
| 对象 | user.name = 'x' | setUser({ ...user, name: 'x' }) |
| 数组增 | list.push(x) | setList([...list, x]) |
| 数组删 | list.splice(i, 1) | setList(list.filter((_, idx) => idx !== i)) |
| 嵌套 | state.a.b = 1 | 逐层展开 { ...s, a: { ...s.a, b: 1 } } |
为什么要新引用?React 用 Object.is 对比 state 判断「要不要重渲染」——原地修改不产生新引用,React 认为「没变」,跳过更新。嵌套深了难以维护时,交给 Immer(kp-021 生态)或 useReducer(kp-016)。
直观类比
快照像相册翻页:count 不是「黑板上的数字」而是「当前这页相册里的数字」。你连说三遍「+1」,三句都是对着同一页照片说的;React 把三张便签贴到下一页,下一页只呈现「最新便签」。函数式更新像便签上写着「在上一张便签的基础上 +1」,于是每张都生效。
实例 / 案例
表单驱动的状态机雏形:
jsxfunction Signup() {
const [form, setForm] = useState({ name: '', agree: false });
function update(field, value) {
setForm(f => ({ ...f, [field]: value }));
}
return (
<form onSubmit={e => { e.preventDefault(); console.log(form); }}>
<input value={form.name} onChange={e => update('name', e.target.value)} />
<label>
<input type="checkbox" checked={form.agree}
onChange={e => update('agree', e.target.checked)} />
同意条款
</label>
<button disabled={!form.agree}>提交</button>
</form>
);
}
注意:form 是一个对象,但每次更新都创建新对象;disabled 由状态派生而非单独维护(「不要存可以算出来的东西」,官方原则之一)。
常见误区
- 连续 setter 读快照:见三次点击例子;规则是「想基于旧值算,就用函数式更新」。
- 原地变异:push/splice 后界面不动,反复排查「为什么没刷新」。
- 在渲染期间调用 setter:
if (x) setState(y)直接写在组件体里且无条件收敛 → 无限循环(渲染 → setState → 渲染 → …)。合法用途是「用上一次 props 校正 state」(if (a !== prevA) setState(...)模式),必须带终止条件。 - state 里存派生值:维护
items又维护total,忘了同步就出 bug——total 应在渲染时items.reduce算出来。 - 以为 state 会立即变:
setCount(c + 1); console.log(count)打印的还是旧值;需要「更新后做事」应放在 effect(kp-010)或事件回调里。
自测题
setCount(count + 1)与setCount(c => c + 1)在「同一事件内调用两次」时结果差多少?为什么?- 为什么 React 选择「快照 + 不可变」而不是「可变 + 通知」?(提示:kp-008 的对比、kp-022 的并发可重入。)
- 下面代码有什么 bug?
``jsx const [todos, setTodos] = useState([]); function remove(id) { todos.splice(todos.findIndex(t => t.id === id), 1); } ``
console.log(count)紧跟在setCount(1)之后为什么不是 1?想在「count 变成 1 之后」执行逻辑,正确姿势是什么?
与其他知识点的关系
- 向前:props(kp-004)是外部输入,state 是内部记忆,两者共同构成 f 的全部输入。
- 向后:kp-008 解释 setter 之后 React 做了什么;kp-014 用闭包机制解释快照为何存在;kp-016 把多字段状态升级为 reducer;kp-025 的 Actions 提供了表单状态的新解法。
延伸阅读
- State: A Component's Memory:<https://react.dev/learn/state-a-components-memory>
- Queueing a Series of State Updates:<https://react.dev/learn/queueing-a-series-of-state-updates>
- Updating Objects / Arrays in State:<https://react.dev/learn/updating-objects-in-state>