useState 到底怎么工作的?

理解状态快照、更新队列和函数式更新

useState 不是把状态保存在函数变量里,而是让 React 在组件对应的 Fiber 上保存状态,并在下一次渲染时把最新状态传回组件。

const [state, setState] = useState(initialState);

返回值包含两部分:当前这次渲染的状态快照,以及用于提交更新的 setter 函数。

1. 状态保存在哪里?

函数组件每次更新时都会重新执行。下面的局部变量不会跨渲染自动保留:

function Counter() {
  let count = 0; // 每次执行 Counter 都会重新初始化
  // ...
}

React 会为组件维护 Fiber,并在 Fiber 的 memoizedState 链表中保存 Hook 信息。可以用下面的简化模型理解:

Fiber
└── memoizedState
    └── Hook(useState) -> Hook(useEffect) -> ...

每次渲染调用 useState 时,React 按 Hook 的调用顺序读取对应节点。因此 Hook 不能放在条件、循环或嵌套函数中,否则同一个位置在不同渲染中可能对应不同 Hook。

这里的“对应”不是在两棵 Fiber 树做 diff 时靠变量名或 Hook 类型匹配出来的,而是在函数组件重新执行时建立的。更新开始后,React 会基于当前已提交的 Fiber 创建 work-in-progress Fiber;执行组件函数时,每遇到一次 Hook 调用,就用 Hook 游标从当前 Fiber 的旧 Hook 链表中取出第 N 个节点,并在 work-in-progress Fiber 上生成对应的新 Hook 节点。

因此 Hook 的匹配关系可以理解为:

第 N 次 Hook 调用
  ↔ current Fiber 上第 N 个旧 Hook
  ↔ work-in-progress Fiber 上第 N 个新 Hook

如果某个 Hook 被写进条件分支,导致这次渲染少调用或多调用了一个 Hook,后续 Hook 的序号就会整体偏移。错位发生在执行函数组件、读取 Hook 链表的阶段,而不是普通子节点 reconciliation 比较阶段。

2. 首次渲染和后续渲染

function Counter() {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount(count + 1)}>{count}</button>;
}

大致流程如下:

  1. 首次渲染:React 创建 Hook 节点,保存初始状态 0,并返回 [0, setCount]
  2. 调用 setter:React 创建一个更新对象,放入该 Hook 的更新队列,并安排组件更新。
  3. 再次渲染:组件重新执行,React 按相同顺序找到这个 Hook,处理队列中的更新。
  4. 得到新快照:React 返回新的 [count, setCount],组件使用新值计算 UI。

setCount 不会直接修改当前已经执行完的函数中的 count。它请求 React 在后续渲染中计算一个新的状态。

3. 状态是“快照”,不是普通变量

function Counter() {
  const [count, setCount] = useState(0);

  function handleClick() {
    console.log(count); // 当前这次渲染中的值
    setCount(count + 1);
    console.log(count); // 仍然是当前快照中的值
  }

  return <button onClick={handleClick}>{count}</button>;
}

事件处理函数捕获的是创建它的那次渲染中的变量。调用 setter 后,当前渲染不会被“就地修改”;React 会在下一次渲染时提供新的状态快照。

这也是下面两次更新最终只增加 1 的原因:

function handleClick() {
  setCount(count + 1);
  setCount(count + 1);
}

如果当前 count0,这两个更新都相当于提交 1,而不是依次读取 01

4. 更新队列与函数式更新

当新状态依赖旧状态时,使用函数式更新:

function handleClick() {
  setCount((value) => value + 1);
  setCount((value) => value + 1);
}

React 会在处理更新队列时依次执行两个 updater:

0 -> updater 1 -> 1 -> updater 2 -> 2

因此最终增加 2

直接传值和函数式更新在构造 work-in-progress Fiber 时没有本质差异:它们都会创建 update,并挂到同一个 Hook 的 queue 上。差异在 update 的 action

setCount(count + 1)
  -> Update(action: 1) // 假设当前渲染快照中的 count 是 0

setCount((value) => value + 1)
  -> Update(action: value => value + 1)

下一次渲染时,React 通过 Hook 游标在 work-in-progress Fiber 上生成对应 Hook,并处理这个 Hook 的 queue。可以把计算逻辑简化理解为:

function basicStateReducer(state, action) {
  return typeof action === "function" ? action(state) : action;
}

所以连续两次 setCount(count + 1) 会得到两个“替换为 1”的 update,最终仍然是 1;连续两次 setCount((value) => value + 1) 会基于队列中上一个 update 的计算结果继续加,最终是 2

函数式更新尤其适合:

  • 连续多次更新同一个状态;
  • 定时器、Promise 或订阅回调中的状态更新;
  • 更新逻辑依赖上一次状态的场景。

如果新值与旧值无关,也可以直接传值:

setStatus("success");

5. 批量更新不等于立即修改状态

React 可能把同一事件或同一批任务中的多个更新合并处理,以减少重新渲染次数。React 18 还扩展了自动批处理的范围,具体内容见批量更新

setCount((value) => value + 1);
setFlag((value) => !value);

即使这些更新最终只触发一次重新渲染,也不能因此认为 setter 会同步修改当前变量。需要在更新后读取最新值时,应通过下一次渲染、effect 或事件流程获取,而不是紧接着读取当前闭包中的变量。

6. 对象和数组必须使用不可变更新

React 通常使用 Object.is 比较新旧状态。如果新旧值是同一个引用,React 可能认为状态没有变化:

const [user, setUser] = useState({ name: "John", age: 18 });

// ❌ 原地修改,引用没有变化
user.age = 19;
setUser(user);

// ✅ 创建新对象
setUser((currentUser) => ({
  ...currentUser,
  age: 19,
}));

数组也应返回新引用:

setItems((items) => [...items, newItem]);

注意,不是所有 setter 调用都会导致组件重新渲染;如果新状态与旧状态相同,React 可以跳过这次更新。

7. 初始值和惰性初始化

如果初始状态的计算成本较高,可以传入初始化函数:

const [data, setData] = useState(() => createExpensiveData());

初始化函数用于计算初始状态,而不是每次渲染都执行。不要把带副作用的操作放进初始化函数;在开发环境 Strict Mode 下,React 可能额外调用初始化逻辑来发现不纯代码,因此初始化函数应保持纯粹。

对于简单值,直接传值即可:

const [count, setCount] = useState(0);

8. useStateuseReducer 如何选择?

两者都用于保存状态,区别主要在更新逻辑的组织方式:

  • useState:状态结构简单,更新逻辑少,直接调用 setter 更直观。
  • useReducer:状态更新类型较多,或者多个字段会一起变化时,用 action 和 reducer 集中管理逻辑更清晰。

无论使用哪一种,都应遵守不可变更新,并使用函数式更新处理依赖旧状态的计算。

常见误区

setter 会立即改变 state

不会。setter 会提交更新,新的 state 要到后续渲染中才会成为当前快照。

连续调用两次 setter 一定渲染两次

不一定。React 可能进行批量更新,具体行为还与 React 版本和更新场景有关。

useState 会深度比较对象

不会。状态比较不是深度比较。对象和数组应通过创建新引用表达变化。

初始值每次渲染都会重新作为 state 使用

不会。初始值只用于初始化对应 Hook;后续渲染会继续使用已有状态。若计算初始值成本高,使用惰性初始化函数。

面试总结

可以这样回答:

useState 的状态并不保存在函数局部变量中,而是由 React 保存在组件 Fiber 对应的 Hook 数据中。调用 setter 时,React 会把更新放入队列并安排重新渲染;下一次渲染时,React 按 Hook 调用顺序处理队列,生成新的状态快照。连续更新旧状态时应使用函数式更新,对象和数组则应通过返回新引用来更新。

记住四个关键词:Fiber、Hook 顺序、更新队列、状态快照