
一句话:
useState的关键细节有三个——初始值传函数才是惰性求值、新值依赖旧值必须用函数式更新、设置相同值会「bailout」但仍可能渲染一次。
一、签名与返回值
const [state, setState] = useState<S>(initialState: S | (() => S));
// ① 直接给值
const [count, setCount] = useState(0);
// ② 传函数(惰性初始化)⭐
const [list, setList] = useState(() => buildBigList());
// ⚠️ 两者的巨大差异
// ❌ 传值:每次渲染都会执行 expensiveCompute(),结果被忽略
const [data, setData] = useState(expensiveCompute());
// ✅ 传函数:只在【首次渲染】执行一次
const [data2, setData2] = useState(() => expensiveCompute());
| 写法 | 执行时机 | 适用 |
|---|---|---|
useState(value) |
每次渲染都会求值(但不使用) | 字面量、简单值 |
useState(() => value) |
仅首次渲染 | 昂贵计算、从 localStorage 读取、大数组构建 |
// 三个典型场景
// ① 昂贵的初始化
const [matrix] = useState(() => createMatrix(1000, 1000));
// ② 读取本地存储(只读一次)
const [token] = useState(() => localStorage.getItem('token') ?? '');
// ③ 从 props 计算初始值
const [list] = useState(() => props.items.filter((x) => x.active));
二、函数式更新
const [count, setCount] = useState(0);
// ① 传值
setCount(5);
setCount(count + 1); // 基于当前渲染的 count 快照
// ② 传函数(接收「最新的待处理值」)
setCount((prev) => prev + 1);
setCount((prev) => Math.max(prev - 1, 0));
// 经典问题复现
function Counter() {
const [count, setCount] = useState(0);
const addThree = () => {
setCount(count + 1); // 三次都传 1 → 结果只加 1
setCount(count + 1);
setCount(count + 1);
};
const addThreeCorrect = () => {
setCount((c) => c + 1); // 0→1→2→3
setCount((c) => c + 1);
setCount((c) => c + 1);
};
return (
<>
<button onClick={addThree}>{count}(错)</button>
<button onClick={addThreeCorrect}>+3(对)</button>
</>
);
}
什么时候必须用函数式更新:
| 场景 | 是否必须 |
|---|---|
| 同一事件里连续多次更新 | ✅ 必须 |
异步回调里更新(setTimeout / Promise / 事件监听) |
✅ 必须 |
在 useEffect 里基于前值更新 |
✅ 必须 |
setXxx 被传给子组件(调用时机不确定) |
✅ 必须 |
| 值直接来自用户输入 / 常量 | ❌ 不必(但写函数式也无害) |
实践建议:只要新值依赖旧值,一律用函数式。这样写几乎没有坏处,还能避免一类隐蔽 bug。
三、更新队列与 bailout
// React 会把同一个 state 的多次 set 放进一个「更新队列」,按顺序消费
setCount((c) => c + 1); // 队列: [fn1]
setCount((c) => c + 1); // 队列: [fn1, fn2]
setCount(100); // 队列: [fn1, fn2, 100]
// 结果 = 100(最后一次直接设值覆盖前面的计算结果)
// bailout:设置「相同的值」时,React 会跳过本次更新
function Comp() {
const [count, setCount] = useState(0);
console.log('render'); // 观察渲染次数
return (
<>
<button onClick={() => setCount(0)}>设为 0</button>
<button onClick={() => setCount((c) => c)}>返回原值</button>
</>
);
}
| 情况 | 行为 |
|---|---|
setCount(0)(当前已是 0) |
React 用 Object.is 比较 → 跳过重渲染 |
setCount((c) => c)(返回原值) |
仍可能执行一次渲染(React 需要先执行更新函数才知道结果) |
| 连续多次相同值 | 会合并,最终不触发渲染 |
关于 bailout 的准确表述(面试加分):
① React 用 Object.is 比较「新旧 state」
② 相同则【可能】跳过重渲染(这是「优化」而非常规行为)
③ 注意:bailout 只影响这个 state 对应的渲染请求;
如果父组件重新渲染或组件里有别的 state 变化,组件依然会重新执行
④ 更重要的:React 在 bailout 时仍可能进入一次渲染(为了执行更新函数),
所以「不重渲染」不能作为业务逻辑的依赖
结论:不要把「设置相同值不会重渲染」当作逻辑保证。需要「相等就跳过」时,显式判断或用
useMemo缓存结果。
四、状态是不可变的
// ❌ 直接修改对象属性(引用不变 → 可能不重渲染)
const [user, setUser] = useState({ name: 'a' });
user.name = 'b';
// ✅ 创建新对象
setUser((prev) => ({ ...prev, name: 'b' }));
// ❌ 直接改数组
const [list, setList] = useState([]);
list.push(item);
// ✅ 创建新数组
setList((prev) => [...prev, item]);
为什么必须不可变(三个原因):
| 原因 | 说明 |
|---|---|
| React 用引用比较决定是否更新 | 直接改对象时引用不变 → React 认为「没变」 |
| 依赖「不可变」的优化机制 | React.memo、useMemo、useCallback 的浅比较都基于引用 |
| 时间旅行与撤销重做 | 每次变化都是独立快照,可以回放 |
// 深层嵌套用 Immer 简化(写法是「直接改」,但产生新对象)
import { produce } from 'immer';
setUser((prev) =>
produce(prev, (draft) => {
draft.profile.address.city = '上海';
draft.tags.push('新标签');
})
);
五、多个 state 的顺序与组织
// 多个 useState 会被依次记录在 Hook 链表上,顺序必须稳定
function Comp() {
const [a] = useState(0); // Hook 0
const [b] = useState(''); // Hook 1
const [c] = useState(false); // Hook 2
// ⚠️ 不能有条件调用,否则顺序错乱
}
如何组织多个状态:
// ① 独立的状态(推荐:变化频率不同就拆开)
const [keyword, setKeyword] = useState('');
const [page, setPage] = useState(1);
const [status, setStatus] = useState<'idle' | 'loading' | 'success'>('idle');
// ② 相关的状态合并成对象(一起变化时)
const [form, setForm] = useState({ name: '', age: 0 });
// ③ 用「状态机」避免非法组合
type State =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: Data }
| { status: 'error'; error: string };
const [state, setState] = useState<State>({ status: 'idle' });
// ❌ 反例:多个布尔值会产生非法组合
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
// → 可能出现「同时 loading 和 error」
// ④ 状态较多时用 useReducer(集中管理 + 便于重置)
const [state, dispatch] = useReducer(reducer, initialState);
六、useState 与「函数值」的陷阱
// ⚠️ 如果 state 本身是函数,传值时会被当成「更新函数」执行
const [fn, setFn] = useState(() => () => console.log('我要存的是这个函数'));
// ✅ 正确写法:包一层
setFn(() => newFunction); // 存新函数
// 说明:useState 无法区分「你传的是更新函数」还是「你要存一个函数」
// 另一个坑:初始值就是函数时也要包
const [handler] = useState(() => myHandler); // ✅ 存的是 myHandler 而非其返回值
// 实际影响(如果确实要存函数)
function useCallbackState<T extends (...args: any[]) => any>(initial: T) {
const [state, setState] = useState(() => initial);
const set = useCallback((next: T | ((prev: T) => T)) => {
setState(() => (typeof next === 'function' ? (next as any)(state) : next));
}, [state]);
return [state, set] as const;
}
七、常见坑
// ① 昂贵初始值没用惰性写法
useState(buildBigData()); // ❌ 每次渲染都执行
useState(() => buildBigData()); // ✅
// ② 连续更新用非函数式
setCount(count + 1);
setCount(count + 1); // ❌ 只加 1
// ③ 直接改 state
list.push(x); // ❌
// ④ 更新对象时忘记展开
setUser({ name: 'x' }); // ❌ 其它字段丢失
// ⑤ 依赖「相同值不重渲染」
setCount(0); // ⚠️ 不能保证逻辑正确性
// ⑥ 把 props 复制进 state
const [value, setValue] = useState(props.value); // ⚠️ props 后续变化不同步
// ⑦ 在渲染中调用 setState
setCount(count + 1); // ❌ 渲染中 → 无限循环
// ⑧ 用 state 存「不需要渲染的值」
const [timerId, setTimerId] = useState(null); // ⚠️ 应该用 useRef
// ⑨ 用 5 个布尔值描述一个状态机
// ⚠️ 会产生非法组合,应该用一个 status 字段
// ⑩ setState 的值来自闭包旧值(异步场景)
setTimeout(() => setCount(count + 1), 1000); // ⚠️ 用 count 的旧快照
面试延伸
- 「
useState的初始值每次渲染都会执行吗?」
取决于写法:useState(expensiveCompute())会每次渲染都执行(只是结果被忽略),useState(() => expensiveCompute())只在首次渲染执行。所以当初始值来自「昂贵计算、读localStorage、构建大数组」时,必须用函数形式(惰性初始化)。 -
「连续三次
setCount(count + 1)为什么只加了 1?」
因为count是当次渲染闭包里的常量快照(值为 0),三次都传1,React 收到三个「设为 1」的请求,最终结果是 1。改成setCount((c) => c + 1)后,React 会在应用每个更新时传入最新的待处理值,于是依次得到 1、2、3。 -
「
useState的设置相同值会重渲染吗?」
React 用Object.is比较新旧值,相同则可能跳过重渲染(这叫 bailout),但这不是可以依赖的保证:① 用函数式更新时 React 需要先执行函数才知道结果,仍可能渲染一次;② 如果父组件渲染或其它 state 变化,组件依然会重新执行。所以不要用「相同值不重渲染」来实现业务逻辑。 -
「为什么必须用不可变更新?」
因为 React 靠引用比较(Object.is)判断状态是否变化。直接改对象/数组时引用不变,React 会认为「没变」而跳过更新,UI 就不刷新。此外不可变性是React.memo/useMemo/useCallback的浅比较机制的前提,也让「时间旅行调试、撤销重做」成为可能。 -
「
useState和useReducer怎么选?」
三个判断标准:① 状态之间有关联、转移逻辑复杂(如多步骤表单、状态机)→useReducer;② 需要把「状态变更逻辑」抽出来测试→useReducer(reducer 是纯函数,易测);③ 只是一个简单值→useState。另外useReducer的dispatch引用稳定(可以安全地传给子组件或放进依赖数组),而setState也稳定——两者在这点上没有差别。 -
「如果 state 本身是一个函数怎么办?」
会遇到「useState无法区分你传的是更新函数还是要存的值」的问题——例如setFn(myFn)会被当作「用myFn计算新状态」而不是「把myFn存起来」。解法是包一层:setFn(() => myFn)。同理初始值也要写成useState(() => myFn)。这是比较冷门但真实的陷阱。 -
「多个相关的 state 应该怎么组织?」
① 一起变化的用一个对象或状态机建模(如{ status, data, error }),避免出现「loading 为 true 同时有 data」这类非法中间态(用布尔值组合最容易出这个问题);② 变化频率差异大的要拆开(避免高频更新带着低频字段重渲染);③ 复杂逻辑用useReducer把「状态 + 转移规则」集中起来,顺便获得「易于测试」与「易于重置」的好处。 -
「
setState的引用稳定吗?可以放进依赖数组吗?」
可以。useState返回的setState在组件的整个生命周期内都是同一个引用(React 保证),所以它可以安全地作为useEffect/useCallback的依赖,也可以放进 props 而不破坏React.memo。不过因为它稳定,通常没必要把它写进依赖数组(exhaustive-deps也会自动忽略它)。
一句话速记
初始值传函数才是惰性求值(
useState(() => compute())只执行一次);新值依赖旧值就用函数式更新setX((p) => ...);设置相同值可能 bailout,但不能依赖这个行为;状态必须不可变更新(对象{...prev}、数组用不可变方法、深嵌套用 Immer);相关状态用对象或状态机建模以避免非法组合(别用一堆布尔值);setState的引用稳定,可以安全放进依赖数组。



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