Skip to content
第 10 章 前端 ⏱ 12 分钟阅读

第 10 章:useState ​

学习目标 ​

  • 深入理解 useState 的工作原理
  • 掌握惰性初始化与函数式更新
  • 处理对象/数组的不可变更新
  • 避开 useState 的 6 个常见坑

一、useState 签名 ​

tsx
const [state, setState] = useState(initialValue);

返回元组:

  • state — 当前值
  • setState — 更新函数

类型推断:

tsx
const [count, setCount] = useState(0);                  // 自动推断 number
const [name, setName] = useState<string>('');            // 显式 string
const [list, setList] = useState<string[]>([]);          // 显式 string[]

二、惰性初始化(性能优化) ​

useState(arg) 的 arg 每次 render 都会先求值——写函数 = 把计算延后,只在首次跑。

tsx
// ❌ 每次 render 都跑 expensiveCalc(JS 执行完才传给 useState)
const [data, setData] = useState(expensiveCalc());

// ✅ 箭头函数包一层,函数本身是引用,只有首次才执行
const [data, setData] = useState(() => expensiveCalc());

关键区别:

写法第 1 次 render第 100 次 render
useState(expensiveCalc())跑函数又跑一次函数(浪费)
useState(() => expensiveCalc())跑函数不跑(只存引用)

为啥:JS 执行 expensiveCalc() 这一步发生在 useState 内部判断之前——不管用不用,函数先跑了。箭头函数本身是引用,本身不耗资源,只有首次 render 才真正调用。

判断标准:计算 > 1ms 就该包函数。

tsx
// ✅ 大计算 / 慢 IO → 包函数
const [theme, setTheme] = useState(() => localStorage.getItem('theme') ?? 'light')
const [config, setConfig] = useState(() => JSON.parse(localStorage.getItem('config')!))
const [list, setList] = useState(() => bigArr.filter(x => x.active))

// ✅ 简单字面量 → 直接写(包函数没意义)
const [count, setCount] = useState(0)
const [name, setName] = useState('')
const [list, setList] = useState([])

⚠️ 坑 1:传对象字面量 { name: '' } 每次 render 也创建新引用,不影响正确性(非首次被丢),但传函数更规范、更省。

三、设置新值的 3 种方式 ​

方式 1:直接传值 ​

tsx
setCount(10);

方式 2:依赖旧值时用函数式更新 ​

tsx
setCount(prev => prev + 1);

方式 3:不依赖 state 的对象/数组 ​

tsx
setUser({ name: '张三' });           // 不基于 user
setUser(prev => ({ ...prev, name: '张三' }));  // 基于 user

四、函数式更新(异步陷阱详解) ​

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

  function tripleAdd() {
    setCount(count + 1);   // 0 + 1 = 1
    setCount(count + 1);   // 0 + 1 = 1(读到的是旧的 count)
    setCount(count + 1);   // 0 + 1 = 1
    // 结果:1(不是 3!)
  }

  function tripleAddFix() {
    setCount(c => c + 1);  // 0 + 1 = 1
    setCount(c => c + 1);  // 1 + 1 = 2
    setCount(c => c + 1);  // 2 + 1 = 3
    // 结果:3 ✅
  }
}

⚠️ 坑 2:异步批处理下,连续多次 setCount(count + 1) 只生效最后一次。

五、对象 State 的更新 ​

tsx
const [user, setUser] = useState({ name: '', age: 18, address: { city: 'bj' } });

// ✅ 改顶层字段
setUser({ ...user, name: '张三' });

// ✅ 改嵌套字段(逐层展开)
setUser({ ...user, address: { ...user.address, city: 'sh' } });

// ✅ 函数式
setUser(prev => ({ ...prev, age: prev.age + 1 }));

⚠️ 坑 3:React 默认浅比较,只比对第一层引用。嵌套对象必须逐层展开才会触发重渲染。

六、数组 State 的更新 ​

tsx
const [list, setList] = useState<number[]>([1, 2, 3]);

// ✅ 追加
setList([...list, 4]);

// ✅ 删除 id=2
setList(list.filter(x => x !== 2));

// ✅ 修改(全部 map)
setList(list.map(x => x === 2 ? 20 : x));

// ✅ 排序
setList([...list].sort((a, b) => b - a));

禁用的方法(直接修改原数组):

tsx
list.push(4);           // ❌
list.pop();             // ❌
list.splice(1, 1);      // ❌
list[0] = 10;           // ❌

⚠️ 坑 4:React 用 Object.is 判断值变化,直接修改原数组引用不变,不会重渲染。

七、自动批处理(React 18) ​

React 18 之前,异步回调里的多次 setState 不会自动合并:

tsx
// React 17
setTimeout(() => {
  setCount(c => c + 1);
  setName('张三');
  // 触发两次渲染
}, 1000);

// React 18(自动批处理)
setTimeout(() => {
  setCount(c => c + 1);
  setName('张三');
  // 只触发一次渲染 ✅
}, 1000);

八、不要在 render 里 setState ​

tsx
function Bad() {
  const [count, setCount] = useState(0);
  setCount(count + 1);   // ⚠️ 无限循环!
  return <div>{count}</div>;
}

会无限触发重渲染,因为每次渲染又调 setter 又触发渲染。

九、实战:Todo 列表 ​

tsx
interface Todo { id: number; text: string; done: boolean; }

function TodoApp() {
  const [todos, setTodos] = useState<Todo[]>([]);
  const [text, setText] = useState('');

  function add() {
    if (!text.trim()) return;
    setTodos(prev => [...prev, { id: Date.now(), text, done: false }]);
    setText('');
  }

  function toggle(id: number) {
    setTodos(prev => prev.map(t => t.id === id ? { ...t, done: !t.done } : t));
  }

  function remove(id: number) {
    setTodos(prev => prev.filter(t => t.id !== id));
  }

  return (
    <>
      <input value={text} onChange={e => setText(e.target.value)} />
      <button onClick={add}>添加</button>
      <ul>
        {todos.map(t => (
          <li key={t.id}>
            <input type="checkbox" checked={t.done} onChange={() => toggle(t.id)} />
            <span style={{ textDecoration: t.done ? 'line-through' : 'none' }}>{t.text}</span>
            <button onClick={() => remove(t.id)}>删</button>
          </li>
        ))}
      </ul>
    </>
  );
}

⚠️ 坑 5:id: Date.now() 在快速点击时可能重复,生产环境用 crypto.randomUUID() 或 nanoid。

用 Immer 简化嵌套更新 ​

嵌套深时,展开复制很啰嗦。Immer = "像改普通对象一样改,自动生成不可变副本"。

bash
npm i immer use-immer    # 两个都装:immer 是核心,use-immer 是 React hook
tsx
import { useImmer } from 'use-immer'

function Comp() {
  const [user, setUser] = useImmer({ name: '', address: { city: 'bj' } })

  function changeCity(city: string) {
    setUser(draft => { draft.address.city = city })  // 直接改 draft
  }
}

对比:

tsx
// 不装 Immer:3 层嵌套展开
setUser({ ...user, address: { ...user.address, city: 'sh' } })

// 装 Immer:直接改
setUser(draft => { draft.address.city = 'sh' })

原理:draft 是 Proxy,你改它,Immer 内部生成新对象,老对象不动。

💡 嵌套深必装,简单数据没必要——团队项目推荐统一用,代码风格统一。

十、本章小结 ​

要点关键
惰性初始化useState(() => expensive())
函数式更新依赖旧值时 setX(prev => ...)
对象更新{...obj, key: value},嵌套逐层展开
数组更新map/filter/slice,不用 push/splice
自动批处理React 18 起所有场景自动合并
不要 render 中 setState会无限循环

动手练习 ​

  1. 计数器:支持步长、归零、最大值限制
  2. Todo 完整版:增删改、勾选完成、清空已完成
  3. 批量更新:用函数式更新让点击一次 +3
  4. 找坑:以下代码错在哪?
    tsx
    const [list, setList] = useState([1, 2, 3]);
    list.push(4);
    setList(list);

下一章:第 11 章:useEffect →

本站基于 VitePress 构建 · 由 StackHub 团队维护