理解 React 状态与渲染:从快照到不可变更新
通过计数器和待办列表理解 React 状态快照、函数式更新与不可变数据,避开日常开发中常见的状态管理陷阱。
React 的状态管理看起来只需要一个 useState,但很多问题都源于同一个误解:把状态当成可以随时改写的普通变量。理解“每次渲染都是一次快照”,比记住 API 更重要。
本文示例适用于函数组件。在 Next.js App Router 中,交互组件需要处于客户端组件边界,例如在文件顶部添加 "use client"。
1. 状态属于某一次渲染
先看一个计数器:
import { useState } from "react";
export function Counter() {
const [count, setCount] = useState(0);
function incrementThreeTimes() {
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
}
return <button onClick={incrementThreeTimes}>当前计数:{count}</button>;
}
点击一次后,计数只增加 1,而不是 3。
原因不是后两次调用被忽略,而是当前事件处理函数里的 count 始终来自同一次渲染。如果快照里的值是 0,三次调用提交的目标值就都是 1。React 会把相关更新批量处理,再生成新的渲染结果。
同理,在调用 setCount 后立刻读取 count,读到的仍然是当前快照。setter 不会修改当前函数里的局部变量。
2. 基于旧值更新时,使用函数式写法
如果下一份状态依赖上一份状态,就把计算过程交给 React:
function incrementThreeTimes() {
setCount(previousCount => previousCount + 1);
setCount(previousCount => previousCount + 1);
setCount(previousCount => previousCount + 1);
}
这里的 setCount 沿用前面的组件。React 会依次处理更新队列,结果就是增加 3。
更新函数应当保持纯粹:只计算并返回新值,不在里面发送请求或生成随机 ID。在开发环境的 Strict Mode 中,React 可能额外调用更新函数来帮助发现不纯的逻辑。
3. 对象和数组要创建新值
直接修改 profile.name,再调用 setProfile(profile),并不是可靠的更新方式。它把同一个对象引用重新交给 React,可能被判定为没有变化,也破坏了上一份状态快照。
正确做法是创建新对象:
setProfile(previousProfile => ({
...previousProfile,
name: "小瓜",
}));
展开语法只进行浅拷贝。如果修改的是 profile.address.city,还需要为 address 创建新对象,而不是继续修改嵌套的旧对象。
4. 一个不可变更新的待办列表
import { useState } from "react";
type Todo = {
id: string;
title: string;
completed: boolean;
};
export function TodoList() {
const [title, setTitle] = useState("");
const [todos, setTodos] = useState<Todo[]>([]);
function addTodo() {
const trimmedTitle = title.trim();
if (!trimmedTitle) return;
const newTodo: Todo = {
id: crypto.randomUUID(),
title: trimmedTitle,
completed: false,
};
setTodos(previousTodos => [...previousTodos, newTodo]);
setTitle("");
}
function toggleTodo(todoId: string) {
setTodos(previousTodos =>
previousTodos.map(todo =>
todo.id === todoId
? { ...todo, completed: !todo.completed }
: todo
)
);
}
const completedCount = todos.filter(todo => todo.completed).length;
return (
<section>
<label>
新任务
<input value={title} onChange={event => setTitle(event.target.value)} />
</label>
<button onClick={addTodo}>添加</button>
<p>已完成 {completedCount} / {todos.length}</p>
<ul>
{todos.map(todo => (
<li key={todo.id}>
<label>
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleTodo(todo.id)}
/>
{todo.title}
</label>
</li>
))}
</ul>
</section>
);
}
这个例子有三个值得保留的习惯:使用稳定 ID 作为 key,在事件处理函数中生成 ID,以及直接从 todos 计算完成数量。
crypto.randomUUID() 适用于支持该 API 的安全浏览器上下文,例如 HTTPS 或 localhost;其他环境应选择合适的 ID 生成方案。
5. 不要保存能推导出来的状态
如果另外保存 completedCount,每次新增、删除和切换任务都必须同步更新它。任何一次遗漏,都会导致两份状态互相矛盾。
优先保存最少的源数据,把统计值、过滤结果和展示文本放在渲染过程中计算。只有测量证明计算昂贵时,才考虑使用 useMemo 缓存。
小结
- 状态是渲染快照,不是 setter 调用后立即变化的局部变量。
- 下一状态依赖上一状态时,优先使用函数式更新。
- 修改对象或数组时,为变化路径创建新引用。
- 能从已有状态推导的值,通常不需要额外保存。
把这四条变成习惯,很多“为什么界面没更新”的问题就会自然消失。