状态的真谛:为什么我们不能直接修改 State?
从“我明明改了 state,界面却不更新”这个痛点出发,讲清不可变数据的意义、React 触发更新的依据、引用相等比较的底层逻辑,以及对象/数组更新的正确姿势
如果你在 React 里遇到过这些现象:
state明明变了,但页面不刷新React.memo/useMemo感觉“失效”或“乱跳”- 列表项改了一个字段,结果别的地方也跟着变
本质上都是同一件事:你把“状态”当成了可随意改的普通变量。
React 的状态系统不是这么设计的——它更像是一套“变化检测 + 渲染调度”的协议。
0. 痛点复现:为什么这样写,UI 不一定更新?
0.1 经典坑:对象字段被改了,但没重新渲染
function Demo() {
const [user, setUser] = React.useState({ name: 'Ada', age: 18 })
const onClick = () => {
user.age += 1 // 直接改对象
setUser(user) // 传回同一个引用
}
return (
<div>
<div>
{user.name} - {user.age}
</div>
<button onClick={onClick}>+1</button>
</div>
)
}
很多时候你会发现:点了按钮,age 在控制台里变了,但 UI 可能不动,或者表现很诡异。
这不是 React “抽风”,而是你触发更新的方式不符合 React 的更新判定逻辑。
1. 先建立一个关键心智模型:React 的更新不是“看值”,而是“看引用”
React 在很多地方都依赖一个简单的事实:
如果两个引用相等(===),就默认认为它们“没有变化”。
这句话特别重要,后面你会反复用到。
对于对象/数组来说:
{}和{}永远不相等(引用不同)const a = {}; const b = a;那么a === b为真(引用相同)
React 的许多优化(以及一些基本机制)都是建立在“引用变化”这件事上的。
2. React 为什么要这样设计?因为它需要“可靠且便宜”的变化检测
想象 React 如果每次更新都用“深度对比”来判断 state 变没变:
- 对象可能很深、很大
- 数据结构可能包含循环引用
- 每次更新都深比对会极其昂贵
React 追求的是:
- 判断变化要快
- 能被优化(memo、跳过渲染)
- 让开发者有明确的规则可遵循
于是最好的折中就是:
用“不可变数据(Immutability)”约束开发者: 你要表达变化,就创建一个新引用。
3. “不能直接改 state”到底是什么意思?
这句话在不同语境下其实有两层含义:
3.1 第一层:你不能通过修改原对象来表达状态变化
你可以“改”一个对象的字段,但 React 不一定认:
- 你改的是对象内部
- 但你交给 React 的可能还是同一个引用
- React 在关键路径上会把它当成“没变”
3.2 第二层:即使 UI 侥幸更新了,也会埋下更大的雷
比如你把同一个对象引用共享给多个地方:
- A 组件持有
user - B 组件也持有同一个
user
你在 A 里直接改 user.age,B 的数据也被“悄悄篡改”了——这会让系统的因果关系变得不可追踪。
不可变数据的意义之一就是:
让变化“显式发生”,而不是“隐式污染”。
4. setState/useState 为什么看起来像“异步”?(顺手把另一个常见困惑讲了)
你可能写过:
setCount(count + 1)
console.log(count) // 还是旧值
这不是 setState 真的“异步”,更准确的理解是:
- React 触发的是一次“更新请求”
- 当前这次渲染里的
count是一个固定快照(snapshot) - 真正的新值会在下一次渲染生效
所以把 state 当成“可变变量”去推理,必错。
更好的心智模型:
每一次渲染,组件拿到的是该次渲染对应的 state 快照。 setState 是申请下一次渲染使用的新快照。
5. 正确姿势:用“创建新引用”来更新对象/数组
5.1 更新对象:用展开运算符或结构化拷贝
✅ 推荐:
setUser((prev) => ({
...prev,
age: prev.age + 1,
}))
这里发生了两件事:
- 创建了一个新对象(新引用)
- 保留旧字段,覆盖 age
React 非常喜欢这种“显式新引用”的变化表达。
5.2 更新数组:不要 push/splice,使用 concat/map/filter
假设你有一个列表:
const [list, setList] = useState([{ id: 1, done: false }])
✅ 新增一项:
setList((prev) => prev.concat({ id: 2, done: false }))
// 或 setList(prev => [...prev, { id: 2, done: false }]);
✅ 删除一项:
setList((prev) => prev.filter((item) => item.id !== 1))
✅ 更新某一项:
setList((prev) =>
prev.map((item) => (item.id === 1 ? { ...item, done: true } : item)),
)
这些写法共同点是:返回一个新数组引用。
6. 为什么“函数式更新”更稳?(强烈建议养成习惯)
你经常会看到这种写法:
setCount((c) => c + 1)
它的价值不止是“避免拿到旧值”,而是:
- 让更新逻辑总是基于 React 当前持有的最新 state
- 避免多次更新合并、批处理等情况下的竞态
- 更新表达更像“规则”,而不是“指令”
对于对象/数组尤其推荐:
setUser(prev => ({ ...prev, age: prev.age + 1 }));
setList(prev => prev.map(...));
7. 这和性能优化有什么关系?非常大(memo 的基础)
React.memo / useMemo / useCallback 大多都依赖“浅比较”:
- props 引用没变 => 认为没变
- 依赖项引用没变 => 认为没变
如果你在原地修改对象,可能出现两种灾难:
- 该更新的不更新(引用没变)
- 不该更新的全更新(你每次都创建新对象但内容没变,导致引用总变)
不可变数据并不是“永远创建新对象”,而是:
只在确实有变化时创建新引用,并且保证变化是可追踪的。
8. 真实项目里最常见的两类坑(你可以自测)
8.1 深层对象更新:只改一层是不够的
// ❌ 错误:只拷贝了第一层
setState({
...state,
profile: state.profile, // 还是旧引用
})
✅ 正确:改到哪一层,就拷贝到哪一层
setState((prev) => ({
...prev,
profile: {
...prev.profile,
address: {
...prev.profile.address,
city: 'Tokyo',
},
},
}))
8.2 把 state 当“单例对象”到处传引用
比如你把一个对象塞进多个地方的缓存、闭包、全局变量里,然后在某处直接改它。
这种 bug 的特点是:
- 表面看“改 A 影响了 B”
- 实际是“共享引用导致的污染”
- 排查起来会非常痛苦
解决方案依旧是那句话:用新引用表达变化,避免共享可变引用。
9. 本文小结:把“状态”当成快照,把“变化”当成新引用
你只需要记住这三条,就能躲开 80% 的坑:
- React 多数情况下用引用相等(===)来判断变化
- 不要原地修改对象/数组,用新引用表达变化
- 优先使用函数式更新:setX(prev => next)
留言讨论
Discussion
欢迎交流与反馈