
一句话:State 是组件的私有可变数据,它的更新会触发重新渲染;而更新必须是「替换」而不是「修改」,并且连续更新要用函数式写法才能拿到最新值。
一、State 是什么
| 维度 | 说明 |
|---|---|
| 归属 | 组件私有(父组件无法直接读写,只能通过 props 传递初值) |
| 可变性 | 通过 setState / setXxx 更新(不能直接改) |
| 作用 | 变化时触发该组件重新渲染 |
| 与 props 的区别 | props 由外部传入且只读;state 由组件自己管理且可变 |
| 生命周期 | 与组件实例绑定(卸载即销毁) |
function Counter() {
// 声明:返回 [当前值, 更新函数]
const [count, setCount] = useState(0);
return (
);
}
关键认知:
useState返回值里的count是当前这次渲染的快照值,是一个常量。调用setCount不会修改count,而是「请求 React 用新值重新渲染这个组件」——下一次渲染时useState会返回新的值。
function Demo() {
const [count, setCount] = useState(0);
const handleClick = () => {
setCount(count + 1);
console.log(count); // ⚠️ 仍然是旧值(当前渲染的快照)
};
return ;
}
二、两种更新方式
const [count, setCount] = useState(0);
// ① 直接传新值
setCount(0);
setCount(count + 1);
setCount(Math.min(count + 1, 10));
// ② 传更新函数(接收「最新的待处理值」,返回新值)
setCount((prev) => prev + 1);
setCount((prev) => Math.min(prev + 1, 10));
| 写法 | 取值来源 | 适合场景 |
|---|---|---|
setCount(count + 1) |
当前渲染的 count 快照 |
简单、单次更新 |
setCount((c) => c + 1) |
React 内部的最新值 | 连续更新、异步回调、依赖前值 |
三、为什么需要函数式更新
// ❌ 经典问题:连续三次 +1,结果只加了 1
function Bad() {
const [count, setCount] = useState(0);
const handleClick = () => {
setCount(count + 1); // 三次都基于同一个 count 快照(都是 0)
setCount(count + 1); // → 相当于三次 setCount(1)
setCount(count + 1);
};
return ; // 点击后显示 1
}
// ✅ 函数式更新:每次都基于「上一次更新后的值」
function Good() {
const [count, setCount] = useState(0);
const handleClick = () => {
setCount((c) => c + 1); // 0 → 1
setCount((c) => c + 1); // 1 → 2
setCount((c) => c + 1); // 2 → 3
};
return ; // 点击后显示 3
}
根本原因:count 是本次渲染闭包里的常量。三次调用都读同一个 count,所以传的都是同一个值;而函数式更新是把「计算逻辑」交给 React,在应用更新时用最新的状态值去执行。
// 异步场景下更容易踩坑
const handleAsync = async () => {
await delay(1000);
setCount(count + 1); // ⚠️ 用的是 1 秒前的 count 快照
};
const handleAsync2 = async () => {
await delay(1000);
setCount((c) => c + 1); // ✅ 永远基于最新值
};
何时必须用函数式更新:
① 同一个事件处理里连续多次更新同一个 state
② 在异步回调(setTimeout / Promise / 事件监听)里更新
③ 在 useEffect 里基于前值更新
④ 把 setXxx 传给子组件时(子组件可能延迟调用)
结论:只要「新值依赖旧值」,就用函数式更新 —— 这是最安全的写法。
四、State 是「替换」不是「合并」
// ⚠️ 与类组件的 setState 不同!函数组件的 useState 是「替换」
const [user, setUser] = useState({ name: '张三', age: 18 });
// ❌ 只改 name,age 会丢失
setUser({ name: '李四' });
// user 变成 { name: '李四' } —— age 没了
// ✅ 展开保留其它字段
setUser({ ...user, name: '李四' });
// ✅ 函数式 + 展开(推荐)
setUser((prev) => ({ ...prev, name: '李四' }));
// 类组件的 setState 是「浅合并」(这是两者的重要差异)
class Demo extends React.Component {
state = { name: '张三', age: 18 };
handleClick = () => {
this.setState({ name: '李四' }); // ✅ age 会保留(浅合并)
};
}
| 维度 | 函数组件 useState |
类组件 setState |
|---|---|---|
| 更新语义 | 替换(不合并) | 浅合并(自动保留其它字段) |
| 写法 | setObj({ ...obj, k: v }) |
setState({ k: v }) |
| 函数式更新 | setX((prev) => ...) |
setState((prev) => ...) |
五、不可变更新的常见模式
1) 对象
const [user, setUser] = useState({ name: 'a', profile: { city: '北京' } });
// 改顶层字段
setUser((prev) => ({ ...prev, name: 'b' }));
// 改嵌套字段(逐层展开)
setUser((prev) => ({
...prev,
profile: { ...prev.profile, city: '上海' },
}));
// ❌ 直接改(React 不会认为状态变了,可能不重渲染)
user.name = 'b';
2) 数组
const [list, setList] = useState- ([]);
// 新增(末尾)
setList((prev) => [...prev, newItem]);
// 新增(开头)
setList((prev) => [newItem, ...prev]);
// 删除
setList((prev) => prev.filter((it) => it.id !== id));
// 修改某一项
setList((prev) => prev.map((it) => (it.id === id ? { ...it, done: true } : it)));
// 插入到指定位置
setList((prev) => [...prev.slice(0, i), newItem, ...prev.slice(i)]);
// 排序(先拷贝再排)
setList((prev) => [...prev].sort((a, b) => a.order - b.order));
// 清空
setList([]);
// ❌ 会直接改原数组的方法
list.push(newItem); // ❌
list.splice(0, 1); // ❌
list.sort(); // ❌(原地排序)
list.reverse(); // ❌
list[0].done = true; // ❌
| 操作 | 不可变写法 | 会改原数组的写法 |
|---|---|---|
| 增 | [...arr, x] |
arr.push(x) |
| 删 | arr.filter(...) |
arr.splice(i, 1) |
| 改 | arr.map(...) |
arr[i] = x |
| 排序 | [...arr].sort() |
arr.sort() |
| 反转 | [...arr].reverse() |
arr.reverse() |
3) 深层嵌套的简化方案
// 方案一:用 Immer 直接「改」(推荐,尤其深嵌套)
import { produce } from 'immer';
setUser((prev) =>
produce(prev, (draft) => {
draft.profile.city = '上海'; // ✅ 写法是「直接改」,但产生新对象
draft.tags.push('新标签');
})
);
// 方案二:useReducer + Immer
const [state, dispatch] = useReducer(
produce((draft, action) => {
switch (action.type) {
case 'rename': draft.user.name = action.name; break;
}
}),
initialState
);
// 方案三:拆分成多个扁平 state(避免深层嵌套)
const [city, setCity] = useState('北京');
const [tags, setTags] = useState([]);
「不可变」为什么重要:
① React 用 Object.is 比较前后 state 的引用决定是否重渲染
→ 直接改对象时引用不变 → React 认为「没变」→ 不重渲染
② 不可变数据让「时间旅行调试」「撤销重做」变得容易(每帧都是独立快照)
③ 与 React.memo / useMemo 的浅比较机制配合(引用稳定才有意义)
六、setState 的「异步」表现
function Demo() {
const [count, setCount] = useState(0);
const handleClick = () => {
setCount(count + 1);
console.log(count); // 0(不是 1)
// 在同一个事件处理里,React 会「批处理」所有更新
// → 只触发一次重渲染,且渲染发生在事件处理函数执行完之后
};
return ;
}
更准确的表述(面试加分点):
① setState 本身是同步的(它只是「入队」一个更新请求)
② 但【应用更新】与【重新渲染】是异步的(在事件处理结束后批处理执行)
③ 所以在事件处理函数内部读不到新值,但在下一次渲染中能看到
React 18 之前的例外:
- 在 setTimeout / Promise / 原生事件监听里更新 → 【不是批处理】→ 立即同步重渲染
React 18+:
- 所有场景都自动批处理(Automatic Batching),包括异步回调
- 需要立即生效时可调用 flushSync(慎用,会破坏批处理)
// 🌟 React 18 前后的差异示例
const handleClick = () => {
setTimeout(() => {
setCount((c) => c + 1);
setA((a) => a + 1);
}, 0);
};
// React 17:两次同步渲染(两次 re-render)
// React 18:合并成一次渲染(自动批处理)
七、State 的设计原则
① 最小状态原则
能算出来的不要存(派生状态):
❌ const [list, setList] = useState([]);
const [count, setCount] = useState(0); // count = list.length
✅ const [list, setList] = useState([]);
const count = list.length; // 渲染时算(或用 useMemo)
② 避免状态冗余 / 重复
❌ 同时存 fullName 和 firstName/lastName → 不同步的风险
✅ 只存 firstName/lastName,fullName 派生出
③ 状态要「扁平」
深嵌套的状态更新麻烦(需要逐层展开),尽量扁平化
④ 状态要「分组」还是「拆分」
- 一起变化的状态可以放一个对象里
- 变化频率差异大的状态要拆开(避免无谓重渲染)
⑤ 状态的归属:放在「需要它的最近的公共父组件」
// 派生状态的反例与正例
function Bad({ list }) {
const [count, setCount] = useState(0);
useEffect(() => {
setCount(list.length); // ❌ 用 effect 同步派生值 → 多一次渲染、易不同步
}, [list]);
return {count};
}
function Good({ list }) {
const count = list.length; // ✅ 直接算
return {count};
}
八、常见坑
// ① 连续更新用非函数式
setCount(count + 1);
setCount(count + 1); // ❌ 只加 1
// ② 直接修改 state(对象/数组)
user.name = 'x'; // ❌ 引用不变 → 不重渲染
list.push(x); // ❌
// ③ 更新对象时忘记展开
setUser({ name: 'x' }); // ❌ 其它字段丢失
// ④ 在渲染中调用 setState
function Comp() {
const [n, setN] = useState(0);
setN(n + 1); // ❌ 无限循环
return {n};
}
// ⑤ 用 useState 存「不需要触发渲染」的值
const [timerId, setTimerId] = useState(null); // ⚠️ 应该用 useRef
// ⑥ 把 props 复制到 state(导致 props 更新后不同步)
function Comp({ initialValue }) {
const [value, setValue] = useState(initialValue); // ⚠️ initialValue 后续变化不会同步
}
// ⑦ 惰性初始化写成函数调用
useState(expensiveCompute()); // ❌ 每次渲染都会执行
useState(() => expensiveCompute()); // ✅ 只在首次渲染执行
// ⑧ 依赖 state 的旧值做计算
const handle = () => setTotal(total + price); // ⚠️ 快速连续点击会算错
const handle2 = () => setTotal((t) => t + price); // ✅
// ⑨ 多个相关联的 state 分开更新导致「中间态」
setLoading(true);
setData(data);
setLoading(false); // ⚠️ 中间会渲染出 loading 与 data 并存的状态
// ✅ 用「状态机」式建模(如 status: 'idle' | 'loading' | 'success')
// ⑨ 的正确做法:用单一状态字段建模
type Status = 'idle' | 'loading' | 'success' | 'error';
const [state, setState] = useState<{
status: Status;
data?: Data;
error?: string;
}>({ status: 'idle' });
// 更新时一次性替换
setState({ status: 'loading' });
setState({ status: 'success', data });
setState({ status: 'error', error: 'msg' });
// → 不存在「loading 且有 data」这种非法组合
面试延伸
- 「
setState是同步还是异步的?」
准确说法是:setState 调用本身是同步的(它只是把更新入队),但「应用更新与重新渲染」是异步的(批处理后在事件处理结束时统一执行)。所以在事件处理函数内部读不到新值,在下一次渲染中才能看到。React 18 之前,在 setTimeout / Promise / 原生事件里更新是同步立即渲染的;React 18 之后所有场景都自动批处理。需要强制同步生效可用 flushSync(但会破坏批处理,慎用)。
- 「为什么连续三次
setCount(count + 1)只加了 1?」
因为 count 是当次渲染闭包里的常量快照(值为 0),三次调用都传入 0 + 1 = 1,React 收到三个「设为 1」的请求,最终结果就是 1。改用函数式更新 setCount((c) => c + 1) 后,React 会在应用每个更新时传入最新的待处理值,于是依次得到 1、2、3。
- 「什么时候必须用函数式更新?」
只要「新值依赖旧值」就应该用。典型场景:① 同一个事件处理里连续多次更新;② 在异步回调(setTimeout、Promise、事件监听)里更新;③ 在 useEffect 里基于前值更新;④ 把 setXxx 作为 props 传给子组件(调用时机不确定)。养成默认写函数式的习惯可以避免大量隐蔽 bug。
- 「
useState和类组件的setState有什么区别?」
最关键的差异是合并语义:类组件的 setState 是浅合并(setState({ name }) 会保留其它字段),而 useState 是替换(setUser({ name }) 会让其它字段丢失,必须写 setUser(prev => ({ ...prev, name })))。此外 useState 可以存任意类型(包括多个独立的 state),而类组件把所有状态放在一个 this.state 对象里。
- 「为什么必须用不可变更新?直接改有什么问题?」
因为 React 用 Object.is 比较前后 state 的引用来决定是否重渲染。直接修改对象/数组时引用不变,React 会认为「状态没变」而跳过重渲染,UI 就不更新了。此外不可变更新让「时间旅行调试、撤销重做、React.memo 的浅比较」都能正常工作——因为这些机制都依赖「每次变化产生新对象」。
- 「什么是派生状态?为什么要避免把它存进 state?」
派生状态是「能从其它 state 或 props 算出来的值」,比如 count = list.length、fullName = firstName + lastName。不应该存进 state,因为:① 需要额外的代码去同步(用 useEffect 同步会多一次渲染且容易不一致);② 存在冗余导致 bug(两个源不同步)。正确做法是在渲染时直接用表达式计算,开销大时用 useMemo 缓存。
- 「
useState的惰性初始化是什么?」
当初始值需要昂贵计算时才用:useState(() => expensiveCompute())。传函数时 React 只在首次渲染调用它;而直接写 useState(expensiveCompute()) 会每次渲染都执行这个函数(只是结果被忽略)。这是一个很容易写的性能陷阱,尤其在初始化一个较大的数组或对象时。
- 「多个相关的 state 应该合并还是拆开?」
判断标准是「它们是否总是一起变化」:① 一起变化的(如 loading / data / error)应该合并成一个对象或用「状态机」建模,避免出现「loading 为 true 同时有 data」这类非法中间态;② 变化频率差异大的应该拆开(比如「输入框的值」与「提交状态」),否则高频更新会带着低频字段一起重渲染;③ 更新逻辑复杂时用 useReducer 把「状态 + 转移规则」集中管理。
一句话速记
state 是组件私有、可变、会触发渲染的数据;
useState返回的变量是当次渲染的快照(常量),setState只是「请求重新渲染」;新值依赖旧值时必须用函数式更新setX((p) => ...);useState是替换而非合并(对象要{...prev, k}、数组要用不可变方法);「异步」表现来自 批处理(React 18+ 全场景自动批处理);不存派生状态、不直接改 state、昂贵的初始值用useState(() => ...)惰性初始化。



最新评论
读过书不知道欧·亨利的人少。教科书上选文有
这小生活不错呀
不错,必须顶一下!
看着你还在坚持,很好
看来忙了也没时间更新博客了
NIce。学习了。。。。
网站不错!!!!
简洁实用,好文章!