React × Vue

useImmer 与复杂状态更新

使用 useImmer 通过 Draft 更新嵌套对象和数组,同时保持 React state 的不可变性。

核心结论

useImmer 允许业务代码修改临时 Draft,Immer 再生成不可变的下一份 state,并复用没有变化的分支。它改善的是复杂本地状态的更新表达,不会改变 React 的 state snapshot 模型。

Draft 到新状态

组件关系与数据流

State SnapshotUI / RenderProducerProxy Draft

更新焦点写法看起来像可变更新,但 React state 没有被直接修改;只有变化路径会创建新引用。

实线表示状态生产过程,虚线表示用户事件和未变化分支的引用复用。

flowchart TB
  accTitle: useImmer 从 Draft 修改到 React 渲染的数据流
  accDescr: 用户事件调用 updatePlan 并传入 producer。Immer 基于当前 state 创建 Proxy Draft,记录嵌套写操作,生成保持不可变性的下一个状态;发生变化的路径获得新引用,未变化分支复用旧引用,最后驱动 React 重新渲染。
  event["用户编辑 / 勾选 / 删除"] -.-> producer{{"updatePlan(producer)"}}
  current(["Current State"]) --> draft[("Proxy Draft")]
  producer -->|"修改嵌套字段"| draft
  draft --> changes{{"Immer 记录变化路径"}}
  changes --> next(["Next State"])
  current -.->|"复用未变化分支"| next
  next --> render["React 重新渲染"]
  class current,next state
  class event,render component
  class producer,changes process
  class draft resource
  classDef state fill:#fffdf8,stroke:#149eca,color:#25221d,stroke-width:1.2px
  classDef component fill:#e4f5fa,stroke:#149eca,color:#25221d,stroke-width:2px,font-weight:600
  classDef process fill:#f4fafb,stroke:#149eca,color:#25221d,stroke-width:1.6px
  classDef resource fill:#eee9df,stroke:#817a70,color:#25221d,stroke-width:1.4px

业务代码修改的是临时 Draft,Immer 负责生成不可变的新状态并执行结构共享。

React 流程图
React 组件关系与数据流

滚轮缩放 · 按住拖动 · 双击重置 · Esc 关闭

完整 Demo 源码

以下代码直接读取在线 Demo 使用的源文件,没有省略或改写。

React 源码

import { useRef, useState } from "react";import type { ChangeEvent, SubmitEvent } from "react";import { useImmer } from "use-immer";import { createInitialPlan } from "./initialPlan";import type { PlanLevel, StudyPlan } from "./initialPlan";import "./ImmerDemo.css";export function ImmerDemo() {  const [plan, updatePlan] = useImmer<StudyPlan>(createInitialPlan);  const [newTaskTitle, setNewTaskTitle] = useState("");  const [targetModuleId, setTargetModuleId] = useState("react");  const nextTaskId = useRef(1);  const tasks = plan.modules.flatMap((module) => module.tasks);  const completedCount = tasks.reduce(    (count, task) => count + Number(task.completed),    0,  );  const progress = tasks.length    ? Math.round((completedCount / tasks.length) * 100)    : 0;  function changePlanTitle(event: ChangeEvent<HTMLInputElement>) {    updatePlan((draft) => {      draft.title = event.target.value;    });  }  function changeOwnerName(event: ChangeEvent<HTMLInputElement>) {    updatePlan((draft) => {      draft.owner.name = event.target.value;    });  }  function changeLevel(event: ChangeEvent<HTMLSelectElement>) {    updatePlan((draft) => {      draft.owner.level = event.target.value as PlanLevel;    });  }  function toggleTask(moduleId: string, taskId: string) {    updatePlan((draft) => {      const module = draft.modules.find((item) => item.id === moduleId);      const task = module?.tasks.find((item) => item.id === taskId);      if (task) task.completed = !task.completed;    });  }  function renameTask(moduleId: string, taskId: string, title: string) {    updatePlan((draft) => {      const module = draft.modules.find((item) => item.id === moduleId);      const task = module?.tasks.find((item) => item.id === taskId);      if (task) task.title = title;    });  }  function removeTask(moduleId: string, taskId: string) {    updatePlan((draft) => {      const module = draft.modules.find((item) => item.id === moduleId);      if (!module) return;      module.tasks = module.tasks.filter((task) => task.id !== taskId);    });  }  function completeModule(moduleId: string) {    updatePlan((draft) => {      const module = draft.modules.find((item) => item.id === moduleId);      if (!module) return;      for (const task of module.tasks) task.completed = true;    });  }  function addTask(event: SubmitEvent<HTMLFormElement>) {    event.preventDefault();    const title = newTaskTitle.trim();    if (!title) return;    updatePlan((draft) => {      const module = draft.modules.find((item) => item.id === targetModuleId);      module?.tasks.push({        id: `custom-${nextTaskId.current}`,        title,        completed: false,      });    });    nextTaskId.current += 1;    setNewTaskTitle("");  }  function resetPlan() {    updatePlan(createInitialPlan());    setNewTaskTitle("");    setTargetModuleId("react");    nextTaskId.current = 1;  }  return (    <div className="immer-demo not-content">      <header className="immer-demo__header">        <div>          <span className="demo-tag">useImmer state</span>          <h2>{plan.title || "未命名学习计划"}</h2>          <p>            {plan.owner.name || "未填写姓名"} · {plan.owner.level}          </p>        </div>        <button className="immer-demo__button" type="button" onClick={resetPlan}>          重置计划        </button>      </header>      <div className="immer-demo__editor">        <label>          计划名称          <input value={plan.title} onChange={changePlanTitle} />        </label>        <label>          学习者          <input value={plan.owner.name} onChange={changeOwnerName} />        </label>        <label>          当前阶段          <select value={plan.owner.level} onChange={changeLevel}>            <option value="初级">初级</option>            <option value="进阶">进阶</option>          </select>        </label>      </div>      <div className="immer-demo__progress">        <div>          <span>整体进度</span>          <strong>            {completedCount} / {tasks.length} 项 · {progress}%          </strong>        </div>        <div          className="immer-demo__progress-track"          role="progressbar"          aria-label="学习计划完成进度"          aria-valuemin={0}          aria-valuemax={100}          aria-valuenow={progress}        >          <span style={{ width: `${progress}%` }} />        </div>      </div>      <div className="immer-demo__modules">        {plan.modules.map((module) => (          <section className="immer-demo__module" key={module.id}>            <header>              <div>                <h3>{module.title}</h3>                <span>                  {module.tasks.filter((task) => task.completed).length} /{" "}                  {module.tasks.length} 完成                </span>              </div>              <button                className="immer-demo__text-button"                type="button"                onClick={() => completeModule(module.id)}                disabled={module.tasks.length === 0}              >                全部完成              </button>            </header>            {module.tasks.length ? (              <ul>                {module.tasks.map((task) => (                  <li                    className={task.completed ? "is-completed" : undefined}                    key={task.id}                  >                    <input                      type="checkbox"                      checked={task.completed}                      aria-label={`${task.completed ? "取消完成" : "完成"} ${task.title}`}                      onChange={() => toggleTask(module.id, task.id)}                    />                    <input                      className="immer-demo__task-title"                      value={task.title}                      aria-label={`编辑任务:${task.title}`}                      onChange={(event) =>                        renameTask(module.id, task.id, event.target.value)                      }                    />                    <button                      className="immer-demo__remove"                      type="button"                      aria-label={`删除任务:${task.title}`}                      onClick={() => removeTask(module.id, task.id)}                    >                      删除                    </button>                  </li>                ))}              </ul>            ) : (              <p className="immer-demo__empty">该模块还没有任务。</p>            )}          </section>        ))}      </div>      <form className="immer-demo__add" onSubmit={addTask}>        <label>          新任务          <input            value={newTaskTitle}            placeholder="例如:完成一次状态重构"            onChange={(event) => setNewTaskTitle(event.target.value)}          />        </label>        <label>          添加到          <select            value={targetModuleId}            onChange={(event) => setTargetModuleId(event.target.value)}          >            {plan.modules.map((module) => (              <option value={module.id} key={module.id}>                {module.title}              </option>            ))}          </select>        </label>        <button          className="immer-demo__button immer-demo__button--primary"          type="submit"          disabled={!newTaskTitle.trim()}        >          添加任务        </button>      </form>    </div>  );}

在线 Demo

修改计划、编辑任务、批量完成或增删任务,观察多层对象与数组如何保持易读的更新代码。

useImmer state

React × Vue 学习计划

小明 · 初级

整体进度2 / 5 项 · 40%

React 核心

1 / 3 完成

Vue 核心

1 / 2 完成

使用边界

  • 只能修改 updater 收到的 draft,不要直接修改当前 React state。
  • 一次 updater 中选择“修改 Draft”或“返回替代状态”,不要混用。
  • 简单布尔值或单层对象通常不需要 Immer,普通 useState 更直接。
  • 业务操作逐渐形成明确事件时,可以进一步使用 useImmerReducer。
  • useImmer 管理本地客户端状态;远程服务端状态应交给TanStack Query。