Appearance
Hooks 心智模型(一):状态与 useState/useReducer
函数组件本来是无状态的纯函数——给什么 props,渲染什么 UI。Hooks 让它能「记住」跨渲染的东西。但 Hooks 不是魔法,它是一组有严格规则的「钩子」。理解状态更新的心智模型(不可变、函数式更新、批处理),你才能避开铺天盖地的 setState 陷阱。
目录
- 引言
- 4.1 Hooks 的两条铁律
- 4.2 useState 心智模型
- 4.3 不可变更新
- 4.4 函数式更新
- 4.5 批处理 Batching
- 4.6 lazy initial state
- 4.7 何时升级为 useReducer
- 4.8 坑汇总
- 小结
- 下一步
引言
React 16.8 之前,函数组件只能读 props、返回 JSX,记不住任何东西。要状态?回去写 Class——this.state、this.setState、生命周期方法 componentDidMount 一整套。Class 的问题不在繁琐,而在状态逻辑难以复用:要复用一段「窗口尺寸监听」逻辑,得用渲染属性(render props)或高阶组件(HOC),层层嵌套,形成「包装地狱」。
Hooks 的设计目标就是把 Class 能做的事——状态、副作用、记忆化——搬进函数组件,同时让逻辑能像普通函数一样组合复用。它改变了这点:
tsx
function Counter() {
const [count, setCount] = useState(0); // 组件「记住」了 count
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}useState 让函数组件在多次渲染之间保留一个值。但每次渲染,函数都是重新执行的——那 count 凭什么能「记住」上次的值?这正是理解 Hooks 的钥匙:状态不在函数变量里,而在 React 替你保管的外部存储里,Hook 只是个「钩子」,把那个值钩进来。本文先建立心智模型,再拆解 useState / useReducer 的用法与陷阱。
4.1 Hooks 的两条铁律
是什么:使用任何 Hook 必须遵守两条不可破坏的规则。
- 只在顶层调用——不能放进
if、循环、嵌套函数里。 - 只在 React 函数组件或自定义 Hook 里调——别在普通函数、事件回调里调。
为什么:React 靠调用顺序定位每个 Hook 对应的状态插槽。它内部维护一个链表,第 1 次调 useState 对应 slot 0,第 2 次对应 slot 1……一旦你把 Hook 放进条件分支:
tsx
function Profile({ isAdmin }: { isAdmin: boolean }) {
const [name, setName] = useState('');
if (isAdmin) {
// ❌ 条件调用:isAdmin 为 false 时这次 Hook 消失,后续 Hook 错位
const [role, setRole] = useState('user');
}
const [age, setAge] = useState(0);
return null;
}当 isAdmin 翻转,Hook 调用数量改变,链表顺序错乱,React 把 age 的值读成了 role 的,状态彻底串台。eslint-plugin-react-hooks 的 rules-of-hooks 规则就是防这个的——务必开启。
第二条规则的推论:自定义 Hook 必须以 use 开头(如 useWindowSize),这是给 linter 和人看的约定——以 use 开头的函数被当作 Hook 检查,组件里调它也合规。反过来,普通工具函数不要以 use 开头,避免误导。
怎么做:把可能改变 Hook 数量的逻辑挪到 Hook 内部、而不是决定「调不调」。比如想根据 isAdmin 决定要不要状态,应写两个组件或用条件设初值,而非条件调用 Hook。
4.2 useState 心智模型
是什么:const [state, setState] = useState(init)。state 是某次渲染的快照,不是「实时最新值的引用」。
为什么重要:很多人把 state 当成普通变量,以为改完立刻是最新的。错。state 在本次渲染期间是固定的——它拍下了渲染那一刻的值。
tsx
function Counter() {
const [count, setCount] = useState(0);
function handleClick() {
setCount(count + 1); // count 这里是 0
setCount(count + 1); // count 还是 0(本次渲染的快照)
setCount(count + 1); // count 仍是 0
// 三次都基于同一个旧快照 → count 最终变成 1,不是 3
}
return <button onClick={handleClick}>{count}</button>;
}你以为点一次涨 3,实际只涨 1。因为三次 setCount(count + 1) 读到的 count 全是本次渲染拍下的 0。这是 useState 最反直觉的一点,下一节的函数式更新就是解药。
换个角度理解:React 渲染像「拍一张照片」。函数体执行时,count 是这张照片里的值,整张照片拍完之前不会变。事件处理器是在「这张照片里」定义的,它看到的 count 就是拍照那一刻的值。多次 setCount(count+1) 都在拿同一张旧照片,自然都算出 1。
心智模型图:
渲染 N: state=0 (快照)
│ setCount(0+1) setCount(0+1) setCount(0+1)
▼ 全部基于快照 0
合并 → pending state = 1
▼
渲染 N+1: state=1 (新快照)4.3 不可变更新
是什么:setState 必须传新值或新引用,不能原地 mutate。
为什么:React 用 Object.is(prev, next) 判断状态变没变。直接改对象属性,引用不变,React 认为没变,不重渲染。
tsx
function Form() {
const [user, setUser] = useState({ name: 'xia', age: 28 });
function wrong() {
user.age += 1; // ❌ 引用没变,React 不触发渲染
setUser(user);
}
function right() {
setUser({ ...user, age: user.age + 1 }); // ✅ 新对象,新引用
}
return <button onClick={right}>{user.age}</button>;
}怎么做——按数据类型:
| 类型 | ❌ 错误(原地改) | ✅ 正确(返回新的) |
|---|---|---|
| 对象 | obj.key = v | { ...obj, key: v } |
| 数组添加 | arr.push(x) | [...arr, x] / arr.concat(x) |
| 数组删除 | arr.splice(i,1) | arr.filter((_, idx) => idx !== i) |
| 数组改项 | arr[i] = x | arr.map((it, idx) => idx === i ? x : it) |
核心原则与站内函数式思维一致:不修改,只生成新数据。这是 React 与函数式编程的天然连接点。
嵌套对象的坑:展开是浅拷贝,只复制一层。深层嵌套要逐层展开。
tsx
const [state, setState] = useState({ user: { address: { city: '北京' } } });
// ❌ 只展开顶层:user/address 仍是旧引用,深层没动
setState({ ...state, user: state.user });
// ✅ 逐层展开,每一层都是新引用
setState({
...state,
user: { ...state.user, address: { ...state.user.address, city: '上海' } },
});深层结构频繁更新很痛,这是为什么复杂状态适合上 useReducer 或状态库(见本系列第 7 篇)。别用 JSON.parse(JSON.stringify()) 深拷贝当常规手段——性能差且丢类型。
4.4 函数式更新
是什么:setState(prev => next),让 React 把最新的状态喂给你。
什么时候必须用:新状态依赖旧状态时——比如计数器累加、基于上一项追加。
tsx
function Counter() {
const [count, setCount] = useState(0);
function handleClick() {
setCount(c => c + 1); // c 是最新值
setCount(c => c + 1);
setCount(c => c + 1);
// ✅ 三次都基于上一次结果 → count 变成 3
}
return <button onClick={handleClick}>{count}</button>;
}对比 4.2 的错误写法,函数式更新是「我依赖最新状态」的明确信号。经验法则:看到 setState(state + x) 这种依赖当前值的写法,就该问一句——「要不要换成函数式更新?」。
什么时候不必用:新状态与旧状态无关时,直接传值更清晰。比如开关 setEnabled(!enabled) 其实依赖旧值,应改函数式;而 setName('xia') 与旧值无关,直接传。判断标准简单——问自己「我要算的是 next,还是要设一个固定值」,前者用函数式,后者不用。
唯一注意:函数式更新里的参数才是可靠的旧值,返回值是全新状态,别在里头搞副作用(发请求、改全局变量)。它该是纯函数,给定 prev 返回 next,否则破坏可预测性,也让并发模式下 React 的可中断渲染出问题。
4.5 批处理(Batching)
是什么:同一事件内的多次 setState,React 会合并成一次重渲染。
为什么:点一次按钮触发 5 个 setState,若每次都重渲染,页面卡死。批处理把它们攒齐,算出最终状态,只渲染一次。
事件开始
setState(a) ┐
setState(b) ├─ 攒齐,不渲染
setState(c) ┘
事件结束 → 一次重渲染React 18 自动批处理:以前只有 React 事件内(如 onClick)才批;18 起事件外也批——Promise、setTimeout、原生事件回调里的多次 setState 同样合并。这修复了 17 时代「异步回调里 setState 不批」的老坑。
tsx
async function handleFetch() {
const data = await api();
setUsers(data.users); // ┐
setLoading(false); // ├─ React 18 合并成一次渲染
setError(null); // ┘
}一个常被问的问题:「我怎么知道哪些 setState 会被批?」答案是:你不用知道。React 18 默认全部批,你只需按直觉写代码,性能交给框架。这也是为什么 4.2 里三次 setCount(count+1) 看似「立刻执行」却只渲染一次——批处理把它们合在一轮。
少数情况你需要「立刻刷」:读取刚设的 DOM 尺寸、同步触发第三方库。这时用 flushSync(fn) 强制在该点冲刷渲染、跳出批处理。但这是逃生口,别常用——它是性能反模式。
推论:不要假设 setState 后下一行就能读到新 state——此刻重渲染还没发生,state 还是旧的。这也解释了 4.8 的「setState 后立刻读 state 是旧的」坑。
4.6 lazy initial state
是什么:useState 接受一个初始化函数,只在首次渲染执行一次。
为什么需要:传值时 useState(makeInitial()),makeInitial() 每次渲染都会执行(因为函数参数先求值),哪怕结果被丢弃,浪费算力。
tsx
function expensiveInit(): State {
// 读 localStorage、解析大 JSON……
return parseHuge();
}
// ❌ 每次渲染都跑 expensiveInit,即便只用首次结果
const [state, setState] = useState(expensiveInit());
// ✅ 惰性初始化:只在首次渲染调用
const [state, setState] = useState(() => expensiveInit());何时用:初始值需要昂贵计算(解析 JSON、深拷贝、读存储)时。简单常量(0、'')直接传值即可,别无脑套函数。
一个常见误用:把派生数据塞进 useState。比如 const [total] = useState(items.length) 是错的——items 变了 total 不会跟着变。派生值不该当状态存,应在渲染时直接算 const total = items.length,每次渲染自然拿到最新值。状态只存「需要记住、且无法从 props/其他 state 推出的」数据。
4.7 何时升级为 useReducer
是什么:useReducer(reducer, initial) 把状态变更集中成 (state, action) => state 的纯函数。
什么时候该升级——满足任一条就考虑:
- 多个状态字段联动(改一个连带改另一个)
- 下一个状态依赖复杂规则
- 状态变更点散落在多处,难追踪
对比:一个表单多字段用 useState,每个字段一个 setState,逻辑碎一地;用 useReducer 集中管理。
tsx
// useState 写法:字段一多就乱
function Form() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [errors, setErrors] = useState<{ username?: string; password?: string }>({});
const [submitting, setSubmitting] = useState(false);
// 提交时要联动改 errors + submitting……散落各处
}tsx
// useReducer 重构:状态变更集中、可预测
type State = {
username: string;
password: string;
errors: { username?: string; password?: string };
submitting: boolean;
};
type Action =
| { type: 'set'; field: 'username' | 'password'; value: string }
| { type: 'submit' }
| { type: 'done'; errors: State['errors'] };
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'set':
return { ...state, [action.field]: action.value };
case 'submit':
return { ...state, submitting: true };
case 'done':
return { ...state, submitting: false, errors: action.errors };
default:
return state;
}
}
function Form() {
const [state, dispatch] = useReducer(reducer, {
username: '', password: '', errors: {}, submitting: false,
});
// dispatch({ type: 'submit' }) 一眼看清发生了什么
}对比表:
| 维度 | useState | useReducer |
|---|---|---|
| 适用 | 独立、简单的状态 | 联动、规则复杂的状态 |
| 变更点 | 分散在各处 | 集中在 reducer |
| 可测试 | 难 | reducer 是纯函数,易测 |
| 心智成本 | 低 | 略高,但状态复杂时反而更清晰 |
经验:两三个字段、无联动 → useState;超过这规模或字段相互影响 → useReducer。
reducer 还有个隐藏优势:它是纯函数,输入 (state, action) 输出 newState,可以脱离 React 单独测试——expect(reducer(init, {type:'submit'})).toEqual(...)。状态越复杂,这个可测试性越值钱。
4.8 坑汇总
1. 闭包陷阱——事件处理器拿到的是渲染那一刻的 state。函数组件每次渲染都生成新函数,捕获当时的 state;定时器、订阅等若引用了旧函数,就拿不到最新值。
tsx
function Timer() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1); // ❌ count 永远是 0(闭包捕获的旧值)
}, 1000);
return () => clearInterval(id);
}, []); // 空依赖,count 被定格为 0
}为什么:[] 让 effect 只跑一次,里面的回调闭包锁死了首次渲染的 count=0,永远加成 1。解药:用函数式更新 setCount(c => c + 1),不依赖闭包里的旧值——React 会把最新 count 喂进来。useEffect 的依赖数组是下一篇的主题,这里先记住「闭包会捕获旧 state」。
2. setState 后立刻读 state 是旧的——批处理还没渲染,state 没更新。
tsx
const [n, setN] = useState(0);
function click() {
setN(5);
console.log(n); // 0,不是 5
}要新值:用函数式更新,或把后续逻辑挪到 useEffect 里监听 n。
3. 直接 mutate state 不渲染——见 4.3,必须传新引用。
4. 依赖闭包变量而非函数式更新——4.4 的连续三次只生效一次。
小结
Hooks 的本质:一组让函数组件在渲染间「记住」东西的钩子,靠调用顺序定位状态插槽。掌握它,抓住三点心智模型:
- state 是渲染快照,不是实时变量——依赖旧值时用函数式更新。这是理解全部 React 状态问题的总钥匙。
- 状态必须不可变——传新引用才触发渲染,与函数式思维一脉相承,数组对象都按不可变习惯写。
- 批处理合并渲染——别在 setState 后立即读新值,多次 setState 同一事件内只渲染一次。
useState 适合简单独立状态;状态联动复杂时,useReducer 把变更集中成可预测的纯函数。记住一个测试:如果某个组件里 useState 超过四五个、且 setState 调用点散落在多个函数、状态间还互相牵扯——就是上 useReducer 的信号。
掌握这四点,你就拿到了状态更新的直觉:快照、不可变、函数式、批处理。下一篇进入副作用——useEffect 的三个真相。
下一步
React 进阶系列 · 第 4 篇 / 共 8 篇
下一篇:Hooks 心智模型(二):副作用与 useEffect 的三个真相 —— 副作用、依赖数组、cleanup,以及那些让你抓狂的陷阱。
上一篇:渲染与 Reconciliation:状态如何变成 DOM —— 渲染触发条件、diff 算法、key 的本质。