西瓜博客
返回所有文章

理解 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 调用后立即变化的局部变量。
  • 下一状态依赖上一状态时,优先使用函数式更新。
  • 修改对象或数组时,为变化路径创建新引用。
  • 能从已有状态推导的值,通常不需要额外保存。

把这四条变成习惯,很多“为什么界面没更新”的问题就会自然消失。