陌上人如玉
公子世无双

1.4 State 与 setState

1.4 State 与 setState

一句话:State 是组件的私有可变数据,它的更新会触发重新渲染;而更新必须是「替换」而不是「修改」,并且连续更新要用函数式写法才能拿到最新值。

一、State 是什么

维度 说明
归属 组件私有(父组件无法直接读写,只能通过 props 传递初值)
可变性 通过 setState / setXxx 更新(不能直接改)
作用 变化时触发该组件重新渲染
与 props 的区别 props 由外部传入且只读;state 由组件自己管理且可变
生命周期 与组件实例绑定(卸载即销毁)
function Counter() {
  // 声明:返回 [当前值, 更新函数]
  const [count, setCount] = useState(0);

  return (
    
  );
}

关键认知:useState 返回值里的 count 是当前这次渲染的快照值,是一个常量。调用 setCount 不会修改 count,而是「请求 React 用新值重新渲染这个组件」——下一次渲染时 useState 会返回新的值。

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

  const handleClick = () => {
    setCount(count + 1);
    console.log(count);      // ⚠️ 仍然是旧值(当前渲染的快照)
  };

  return ;
}

二、两种更新方式

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

// ① 直接传新值
setCount(0);
setCount(count + 1);
setCount(Math.min(count + 1, 10));

// ② 传更新函数(接收「最新的待处理值」,返回新值)
setCount((prev) => prev + 1);
setCount((prev) => Math.min(prev + 1, 10));
写法 取值来源 适合场景
setCount(count + 1) 当前渲染的 count 快照 简单、单次更新
setCount((c) => c + 1) React 内部的最新值 连续更新、异步回调、依赖前值

三、为什么需要函数式更新

// ❌ 经典问题:连续三次 +1,结果只加了 1
function Bad() {
  const [count, setCount] = useState(0);

  const handleClick = () => {
    setCount(count + 1);      // 三次都基于同一个 count 快照(都是 0)
    setCount(count + 1);      // → 相当于三次 setCount(1)
    setCount(count + 1);
  };

  return ;   // 点击后显示 1
}

// ✅ 函数式更新:每次都基于「上一次更新后的值」
function Good() {
  const [count, setCount] = useState(0);

  const handleClick = () => {
    setCount((c) => c + 1);   // 0 → 1
    setCount((c) => c + 1);   // 1 → 2
    setCount((c) => c + 1);   // 2 → 3
  };

  return ;   // 点击后显示 3
}

根本原因:count 是本次渲染闭包里的常量。三次调用都读同一个 count,所以传的都是同一个值;而函数式更新是把「计算逻辑」交给 React,在应用更新时用最新的状态值去执行。

// 异步场景下更容易踩坑
const handleAsync = async () => {
  await delay(1000);
  setCount(count + 1);        // ⚠️ 用的是 1 秒前的 count 快照
};

const handleAsync2 = async () => {
  await delay(1000);
  setCount((c) => c + 1);     // ✅ 永远基于最新值
};

何时必须用函数式更新:

① 同一个事件处理里连续多次更新同一个 state
② 在异步回调(setTimeout / Promise / 事件监听)里更新
③ 在 useEffect 里基于前值更新
④ 把 setXxx 传给子组件时(子组件可能延迟调用)

结论:只要「新值依赖旧值」,就用函数式更新 —— 这是最安全的写法。

四、State 是「替换」不是「合并」

// ⚠️ 与类组件的 setState 不同!函数组件的 useState 是「替换」
const [user, setUser] = useState({ name: '张三', age: 18 });

// ❌ 只改 name,age 会丢失
setUser({ name: '李四' });
// user 变成 { name: '李四' } —— age 没了

// ✅ 展开保留其它字段
setUser({ ...user, name: '李四' });

// ✅ 函数式 + 展开(推荐)
setUser((prev) => ({ ...prev, name: '李四' }));
// 类组件的 setState 是「浅合并」(这是两者的重要差异)
class Demo extends React.Component {
  state = { name: '张三', age: 18 };

  handleClick = () => {
    this.setState({ name: '李四' });   // ✅ age 会保留(浅合并)
  };
}
维度 函数组件 useState 类组件 setState
更新语义 替换(不合并) 浅合并(自动保留其它字段)
写法 setObj({ ...obj, k: v }) setState({ k: v })
函数式更新 setX((prev) => ...) setState((prev) => ...)

五、不可变更新的常见模式

1) 对象

const [user, setUser] = useState({ name: 'a', profile: { city: '北京' } });

// 改顶层字段
setUser((prev) => ({ ...prev, name: 'b' }));

// 改嵌套字段(逐层展开)
setUser((prev) => ({
  ...prev,
  profile: { ...prev.profile, city: '上海' },
}));

// ❌ 直接改(React 不会认为状态变了,可能不重渲染)
user.name = 'b';

2) 数组

const [list, setList] = useState([]);

// 新增(末尾)
setList((prev) => [...prev, newItem]);
// 新增(开头)
setList((prev) => [newItem, ...prev]);
// 删除
setList((prev) => prev.filter((it) => it.id !== id));
// 修改某一项
setList((prev) => prev.map((it) => (it.id === id ? { ...it, done: true } : it)));
// 插入到指定位置
setList((prev) => [...prev.slice(0, i), newItem, ...prev.slice(i)]);
// 排序(先拷贝再排)
setList((prev) => [...prev].sort((a, b) => a.order - b.order));
// 清空
setList([]);

// ❌ 会直接改原数组的方法
list.push(newItem);            // ❌
list.splice(0, 1);             // ❌
list.sort();                   // ❌(原地排序)
list.reverse();                // ❌
list[0].done = true;           // ❌
操作 不可变写法 会改原数组的写法
增 [...arr, x] arr.push(x)
删 arr.filter(...) arr.splice(i, 1)
改 arr.map(...) arr[i] = x
排序 [...arr].sort() arr.sort()
反转 [...arr].reverse() arr.reverse()

3) 深层嵌套的简化方案

// 方案一:用 Immer 直接「改」(推荐,尤其深嵌套)
import { produce } from 'immer';

setUser((prev) =>
  produce(prev, (draft) => {
    draft.profile.city = '上海';            // ✅ 写法是「直接改」,但产生新对象
    draft.tags.push('新标签');
  })
);

// 方案二:useReducer + Immer
const [state, dispatch] = useReducer(
  produce((draft, action) => {
    switch (action.type) {
      case 'rename': draft.user.name = action.name; break;
    }
  }),
  initialState
);

// 方案三:拆分成多个扁平 state(避免深层嵌套)
const [city, setCity] = useState('北京');
const [tags, setTags] = useState([]);
「不可变」为什么重要:
① React 用 Object.is 比较前后 state 的引用决定是否重渲染
   → 直接改对象时引用不变 → React 认为「没变」→ 不重渲染
② 不可变数据让「时间旅行调试」「撤销重做」变得容易(每帧都是独立快照)
③ 与 React.memo / useMemo 的浅比较机制配合(引用稳定才有意义)

六、setState 的「异步」表现

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

  const handleClick = () => {
    setCount(count + 1);
    console.log(count);          // 0(不是 1)

    // 在同一个事件处理里,React 会「批处理」所有更新
    // → 只触发一次重渲染,且渲染发生在事件处理函数执行完之后
  };

  return ;
}
更准确的表述(面试加分点):
  ① setState 本身是同步的(它只是「入队」一个更新请求)
  ② 但【应用更新】与【重新渲染】是异步的(在事件处理结束后批处理执行)
  ③ 所以在事件处理函数内部读不到新值,但在下一次渲染中能看到

React 18 之前的例外:
  - 在 setTimeout / Promise / 原生事件监听里更新 → 【不是批处理】→ 立即同步重渲染
React 18+:
  - 所有场景都自动批处理(Automatic Batching),包括异步回调
  - 需要立即生效时可调用 flushSync(慎用,会破坏批处理)
// 🌟 React 18 前后的差异示例
const handleClick = () => {
  setTimeout(() => {
    setCount((c) => c + 1);
    setA((a) => a + 1);
  }, 0);
};
// React 17:两次同步渲染(两次 re-render)
// React 18:合并成一次渲染(自动批处理)

七、State 的设计原则

① 最小状态原则
   能算出来的不要存(派生状态):
   ❌ const [list, setList] = useState([]);
      const [count, setCount] = useState(0);   // count = list.length
   ✅ const [list, setList] = useState([]);
      const count = list.length;               // 渲染时算(或用 useMemo)

② 避免状态冗余 / 重复
   ❌ 同时存 fullName 和 firstName/lastName → 不同步的风险
   ✅ 只存 firstName/lastName,fullName 派生出

③ 状态要「扁平」
   深嵌套的状态更新麻烦(需要逐层展开),尽量扁平化

④ 状态要「分组」还是「拆分」
   - 一起变化的状态可以放一个对象里
   - 变化频率差异大的状态要拆开(避免无谓重渲染)

⑤ 状态的归属:放在「需要它的最近的公共父组件」
// 派生状态的反例与正例
function Bad({ list }) {
  const [count, setCount] = useState(0);

  useEffect(() => {
    setCount(list.length);            // ❌ 用 effect 同步派生值 → 多一次渲染、易不同步
  }, [list]);

  return 
{count}
; } function Good({ list }) { const count = list.length; // ✅ 直接算 return
{count}
; }

八、常见坑

// ① 连续更新用非函数式
setCount(count + 1);
setCount(count + 1);                  // ❌ 只加 1

// ② 直接修改 state(对象/数组)
user.name = 'x';                      // ❌ 引用不变 → 不重渲染
list.push(x);                         // ❌

// ③ 更新对象时忘记展开
setUser({ name: 'x' });               // ❌ 其它字段丢失

// ④ 在渲染中调用 setState
function Comp() {
  const [n, setN] = useState(0);
  setN(n + 1);                        // ❌ 无限循环
  return 
{n}
; } // ⑤ 用 useState 存「不需要触发渲染」的值 const [timerId, setTimerId] = useState(null); // ⚠️ 应该用 useRef // ⑥ 把 props 复制到 state(导致 props 更新后不同步) function Comp({ initialValue }) { const [value, setValue] = useState(initialValue); // ⚠️ initialValue 后续变化不会同步 } // ⑦ 惰性初始化写成函数调用 useState(expensiveCompute()); // ❌ 每次渲染都会执行 useState(() => expensiveCompute()); // ✅ 只在首次渲染执行 // ⑧ 依赖 state 的旧值做计算 const handle = () => setTotal(total + price); // ⚠️ 快速连续点击会算错 const handle2 = () => setTotal((t) => t + price); // ✅ // ⑨ 多个相关联的 state 分开更新导致「中间态」 setLoading(true); setData(data); setLoading(false); // ⚠️ 中间会渲染出 loading 与 data 并存的状态 // ✅ 用「状态机」式建模(如 status: 'idle' | 'loading' | 'success')
// ⑨ 的正确做法:用单一状态字段建模
type Status = 'idle' | 'loading' | 'success' | 'error';

const [state, setState] = useState<{
  status: Status;
  data?: Data;
  error?: string;
}>({ status: 'idle' });

// 更新时一次性替换
setState({ status: 'loading' });
setState({ status: 'success', data });
setState({ status: 'error', error: 'msg' });
// → 不存在「loading 且有 data」这种非法组合

面试延伸

  1. 「setState 是同步还是异步的?」

准确说法是:setState 调用本身是同步的(它只是把更新入队),但「应用更新与重新渲染」是异步的(批处理后在事件处理结束时统一执行)。所以在事件处理函数内部读不到新值,在下一次渲染中才能看到。React 18 之前,在 setTimeout / Promise / 原生事件里更新是同步立即渲染的;React 18 之后所有场景都自动批处理。需要强制同步生效可用 flushSync(但会破坏批处理,慎用)。

  1. 「为什么连续三次 setCount(count + 1) 只加了 1?」

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

  1. 「什么时候必须用函数式更新?」

只要「新值依赖旧值」就应该用。典型场景:① 同一个事件处理里连续多次更新;② 在异步回调(setTimeout、Promise、事件监听)里更新;③ 在 useEffect 里基于前值更新;④ 把 setXxx 作为 props 传给子组件(调用时机不确定)。养成默认写函数式的习惯可以避免大量隐蔽 bug。

  1. 「useState 和类组件的 setState 有什么区别?」

最关键的差异是合并语义:类组件的 setState 是浅合并(setState({ name }) 会保留其它字段),而 useState 是替换(setUser({ name }) 会让其它字段丢失,必须写 setUser(prev => ({ ...prev, name })))。此外 useState 可以存任意类型(包括多个独立的 state),而类组件把所有状态放在一个 this.state 对象里。

  1. 「为什么必须用不可变更新?直接改有什么问题?」

因为 React 用 Object.is 比较前后 state 的引用来决定是否重渲染。直接修改对象/数组时引用不变,React 会认为「状态没变」而跳过重渲染,UI 就不更新了。此外不可变更新让「时间旅行调试、撤销重做、React.memo 的浅比较」都能正常工作——因为这些机制都依赖「每次变化产生新对象」。

  1. 「什么是派生状态?为什么要避免把它存进 state?」

派生状态是「能从其它 state 或 props 算出来的值」,比如 count = list.length、fullName = firstName + lastName。不应该存进 state,因为:① 需要额外的代码去同步(用 useEffect 同步会多一次渲染且容易不一致);② 存在冗余导致 bug(两个源不同步)。正确做法是在渲染时直接用表达式计算,开销大时用 useMemo 缓存。

  1. 「useState 的惰性初始化是什么?」

当初始值需要昂贵计算时才用:useState(() => expensiveCompute())。传函数时 React 只在首次渲染调用它;而直接写 useState(expensiveCompute()) 会每次渲染都执行这个函数(只是结果被忽略)。这是一个很容易写的性能陷阱,尤其在初始化一个较大的数组或对象时。

  1. 「多个相关的 state 应该合并还是拆开?」

判断标准是「它们是否总是一起变化」:① 一起变化的(如 loading / data / error)应该合并成一个对象或用「状态机」建模,避免出现「loading 为 true 同时有 data」这类非法中间态;② 变化频率差异大的应该拆开(比如「输入框的值」与「提交状态」),否则高频更新会带着低频字段一起重渲染;③ 更新逻辑复杂时用 useReducer 把「状态 + 转移规则」集中管理。

一句话速记

state 是组件私有、可变、会触发渲染的数据;useState 返回的变量是当次渲染的快照(常量),setState 只是「请求重新渲染」;新值依赖旧值时必须用函数式更新 setX((p) => ...);useState 是替换而非合并(对象要 {...prev, k}、数组要用不可变方法);「异步」表现来自 批处理(React 18+ 全场景自动批处理);不存派生状态、不直接改 state、昂贵的初始值用 useState(() => ...) 惰性初始化。

赞(0) 打赏
未经允许不得转载:陌上寒 » 1.4 State 与 setState

评论 抢沙发

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

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

微信扫一扫

支付宝扫一扫