第 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 hooktsx
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 | 会无限循环 |
动手练习
- 计数器:支持步长、归零、最大值限制
- Todo 完整版:增删改、勾选完成、清空已完成
- 批量更新:用函数式更新让点击一次 +3
- 找坑:以下代码错在哪?tsx
const [list, setList] = useState([1, 2, 3]); list.push(4); setList(list);
下一章:第 11 章:useEffect →