mCell
2 / 14

状态的真谛:为什么我们不能直接修改 State?

从“我明明改了 state,界面却不更新”这个痛点出发,讲清不可变数据的意义、React 触发更新的依据、引用相等比较的底层逻辑,以及对象/数组更新的正确姿势

如果你在 React 里遇到过这些现象:

  • state 明明变了,但页面不刷新
  • React.memo/useMemo 感觉“失效”或“乱跳”
  • 列表项改了一个字段,结果别的地方也跟着变

本质上都是同一件事:你把“状态”当成了可随意改的普通变量
React 的状态系统不是这么设计的——它更像是一套“变化检测 + 渲染调度”的协议。


0. 痛点复现:为什么这样写,UI 不一定更新?

0.1 经典坑:对象字段被改了,但没重新渲染

jsx
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 为什么看起来像“异步”?(顺手把另一个常见困惑讲了)

你可能写过:

jsx
setCount(count + 1) console.log(count) // 还是旧值

这不是 setState 真的“异步”,更准确的理解是:

  • React 触发的是一次“更新请求”
  • 当前这次渲染里的 count 是一个固定快照(snapshot)
  • 真正的新值会在下一次渲染生效

所以把 state 当成“可变变量”去推理,必错。

更好的心智模型:

每一次渲染,组件拿到的是该次渲染对应的 state 快照。 setState 是申请下一次渲染使用的新快照。


5. 正确姿势:用“创建新引用”来更新对象/数组

5.1 更新对象:用展开运算符或结构化拷贝

✅ 推荐:

jsx
setUser((prev) => ({ ...prev, age: prev.age + 1, }))

这里发生了两件事:

  1. 创建了一个新对象(新引用)
  2. 保留旧字段,覆盖 age

React 非常喜欢这种“显式新引用”的变化表达。


5.2 更新数组:不要 push/splice,使用 concat/map/filter

假设你有一个列表:

jsx
const [list, setList] = useState([{ id: 1, done: false }])

✅ 新增一项:

jsx
setList((prev) => prev.concat({ id: 2, done: false })) // 或 setList(prev => [...prev, { id: 2, done: false }]);

✅ 删除一项:

jsx
setList((prev) => prev.filter((item) => item.id !== 1))

✅ 更新某一项:

jsx
setList((prev) => prev.map((item) => (item.id === 1 ? { ...item, done: true } : item)), )

这些写法共同点是:返回一个新数组引用


6. 为什么“函数式更新”更稳?(强烈建议养成习惯)

你经常会看到这种写法:

jsx
setCount((c) => c + 1)

它的价值不止是“避免拿到旧值”,而是:

  • 让更新逻辑总是基于 React 当前持有的最新 state
  • 避免多次更新合并、批处理等情况下的竞态
  • 更新表达更像“规则”,而不是“指令”

对于对象/数组尤其推荐:

jsx
setUser(prev => ({ ...prev, age: prev.age + 1 })); setList(prev => prev.map(...));

7. 这和性能优化有什么关系?非常大(memo 的基础)

React.memo / useMemo / useCallback 大多都依赖“浅比较”:

  • props 引用没变 => 认为没变
  • 依赖项引用没变 => 认为没变

如果你在原地修改对象,可能出现两种灾难:

  1. 该更新的不更新(引用没变)
  2. 不该更新的全更新(你每次都创建新对象但内容没变,导致引用总变)

不可变数据并不是“永远创建新对象”,而是:

只在确实有变化时创建新引用,并且保证变化是可追踪的。


8. 真实项目里最常见的两类坑(你可以自测)

8.1 深层对象更新:只改一层是不够的

js
// ❌ 错误:只拷贝了第一层 setState({ ...state, profile: state.profile, // 还是旧引用 })

✅ 正确:改到哪一层,就拷贝到哪一层

js
setState((prev) => ({ ...prev, profile: { ...prev.profile, address: { ...prev.profile.address, city: 'Tokyo', }, }, }))

8.2 把 state 当“单例对象”到处传引用

比如你把一个对象塞进多个地方的缓存、闭包、全局变量里,然后在某处直接改它。

这种 bug 的特点是:

  • 表面看“改 A 影响了 B”
  • 实际是“共享引用导致的污染”
  • 排查起来会非常痛苦

解决方案依旧是那句话:用新引用表达变化,避免共享可变引用。


9. 本文小结:把“状态”当成快照,把“变化”当成新引用

你只需要记住这三条,就能躲开 80% 的坑:

  1. React 多数情况下用引用相等(===)来判断变化
  2. 不要原地修改对象/数组,用新引用表达变化
  3. 优先使用函数式更新:setX(prev => next)

留言讨论

Discussion

欢迎交流与反馈