陌上人如玉
公子世无双

2.2 useState 深入

本文于 2026-10-12 10:36 更新,部分内容具有时效性,如有失效,请留言

2.2 useState 深入

一句话: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 的旧快照

面试延伸

  1. 「useState 的初始值每次渲染都会执行吗?」
    取决于写法:useState(expensiveCompute()) 会每次渲染都执行(只是结果被忽略),useState(() => expensiveCompute()) 只在首次渲染执行。所以当初始值来自「昂贵计算、读 localStorage、构建大数组」时,必须用函数形式(惰性初始化)。

  2. 「连续三次 setCount(count + 1) 为什么只加了 1?」
    因为 count 是当次渲染闭包里的常量快照(值为 0),三次都传 1,React 收到三个「设为 1」的请求,最终结果是 1。改成 setCount((c) => c + 1) 后,React 会在应用每个更新时传入最新的待处理值,于是依次得到 1、2、3。

  3. 「useState 的设置相同值会重渲染吗?」
    React 用 Object.is 比较新旧值,相同则可能跳过重渲染(这叫 bailout),但这不是可以依赖的保证:① 用函数式更新时 React 需要先执行函数才知道结果,仍可能渲染一次;② 如果父组件渲染或其它 state 变化,组件依然会重新执行。所以不要用「相同值不重渲染」来实现业务逻辑。

  4. 「为什么必须用不可变更新?」
    因为 React 靠引用比较(Object.is)判断状态是否变化。直接改对象/数组时引用不变,React 会认为「没变」而跳过更新,UI 就不刷新。此外不可变性是 React.memo / useMemo / useCallback 的浅比较机制的前提,也让「时间旅行调试、撤销重做」成为可能。

  5. 「useState 和 useReducer 怎么选?」
    三个判断标准:① 状态之间有关联、转移逻辑复杂(如多步骤表单、状态机)→ useReducer;② 需要把「状态变更逻辑」抽出来测试→ useReducer(reducer 是纯函数,易测);③ 只是一个简单值→ useState。另外 useReducer 的 dispatch 引用稳定(可以安全地传给子组件或放进依赖数组),而 setState 也稳定——两者在这点上没有差别。

  6. 「如果 state 本身是一个函数怎么办?」
    会遇到「useState 无法区分你传的是更新函数还是要存的值」的问题——例如 setFn(myFn) 会被当作「用 myFn 计算新状态」而不是「把 myFn 存起来」。解法是包一层:setFn(() => myFn)。同理初始值也要写成 useState(() => myFn)。这是比较冷门但真实的陷阱。

  7. 「多个相关的 state 应该怎么组织?」
    ① 一起变化的用一个对象或状态机建模(如 { status, data, error }),避免出现「loading 为 true 同时有 data」这类非法中间态(用布尔值组合最容易出这个问题);② 变化频率差异大的要拆开(避免高频更新带着低频字段重渲染);③ 复杂逻辑用 useReducer 把「状态 + 转移规则」集中起来,顺便获得「易于测试」与「易于重置」的好处。

  8. 「setState 的引用稳定吗?可以放进依赖数组吗?」
    可以。useState 返回的 setState 在组件的整个生命周期内都是同一个引用(React 保证),所以它可以安全地作为 useEffect / useCallback 的依赖,也可以放进 props 而不破坏 React.memo。不过因为它稳定,通常没必要把它写进依赖数组(exhaustive-deps 也会自动忽略它)。

一句话速记

初始值传函数才是惰性求值(useState(() => compute()) 只执行一次);新值依赖旧值就用函数式更新 setX((p) => ...);设置相同值可能 bailout,但不能依赖这个行为;状态必须不可变更新(对象 {...prev}、数组用不可变方法、深嵌套用 Immer);相关状态用对象或状态机建模以避免非法组合(别用一堆布尔值);setState 的引用稳定,可以安全放进依赖数组。

赞(0) 打赏
未经允许不得转载:陌上寒 » 2.2 useState 深入

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫