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。

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。函数式更新尤其适合:

  • 连续多次更新同一个状态;
  • 定时器、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 顺序、更新队列、状态快照