R18第 1 / 1 课时未开始内容版本 1.0.0 · 核验于 2026-10-26

useReducer 与状态转移

useReducer 与状态转移 · 阅读后完成下方实验与检查题,再用「标记完成」记录进度。

学习目标

练习提交这种多步骤流程,用四五个 useState 拼会越来越乱。读完你应该能用 useReducer 把整条状态转移收进一个纯函数,用动作名表达业务变化,并会测试非法操作被正确拒绝。

问题场景

做题页面有完整生命周期:编辑中 → 提交中 → 已批改,编辑中可以改标题和答案,提交中要锁住输入,已批改后只能重置。用 useState 写是这样:

const [step, setStep] = useState("idle");
const [title, setTitle] = useState("");
const [answer, setAnswer] = useState("");
const [log, setLog] = useState([]);

function handleSubmit() {
  if (step !== "editing" || !answer.trim()) return; // 校验散落在事件里
  setStep("submitting");
  setLog(l => [...l, "提交中…"]);
}

每个操作都得先「看一眼现在是什么阶段,再决定改哪几个字段」。阶段越多,这些 if 就越分散,读代码的人拼不出完整的状态机。

核心解释

useReducer 的思路是反过来:把「现在是什么阶段」和「能做什么」收进一个纯函数。组件只负责发动作(dispatch),reducer 负责判断这个动作在当前状态下合不合法、状态该怎么变:

function reducer(state, action) {
  switch (action.type) {
    case "SUBMIT":
      if (state.step !== "editing") return state; // 非法操作:原样返回
      if (!state.answer.trim()) return { ...state, log: [...state.log, "提交被拒绝"] };
      return { ...state, step: "submitting", log: [...state.log, "提交中…"] };
    case "RESOLVE":
      if (state.step !== "submitting") return state;
      return { ...state, step: "done", log: [...state.log, "批改完成"] };
    // ...
  }
}

三个关键约定:

  • 动作表达业务意图,不是赋值语句:叫 SUBMIT 而不是 SET_STEP_SUBMITTING,动作名就是需求文档。
  • reducer 必须是纯函数:同样的 state 和 action 永远算出同样的结果,不发请求、不改外部变量。副作用留在组件的事件处理里。
  • 非法操作直接忽略(返回原 state):调用方不用预判状态,reducer 是唯一闸门。整个状态机的合法路径一目了然:idle 只能进 editing,editing 才能提交,submitting 才能出结果。

示例拆解

实验区就是这套 reducer 驱动的提交流程:

  • 界面按 state.step 渲染:idle 显示「开始做题」,editing 显示标题、答案输入和提交按钮,submitting 锁输入,done 显示结果和重置;
  • 「模拟批改」按钮 dispatch RESOLVE;你在 submitting 阶段外点它,界面纹丝不动——reducer 把它当成无效操作吞掉了;
  • 日志面板记录每次转移:进入编辑 / 提交中 / 批改完成 / 提交被拒绝,正好对应 reducer 里的每个 return;
  • 验证方式:故意在 idle 点「提交」,日志出现「提交被拒绝」而不是状态错乱;清空答案提交同样被拒。

动手任务

打开本课实验区,完成三个小改动:

  1. 新增 CHANGE 动作,只在 editing 阶段生效,让标题和答案可编辑。
  2. 新增 FAIL 动作:submitting 阶段收到时回到 editing,日志记「批改失败」,其他阶段忽略。
  3. 给 SUBMIT 加一条规则:答案少于 2 个字时拒绝并在日志里写明原因。

常见错误

  • 在 reducer 里写副作用:发请求、setTimeout、改数组之外的变量都不行;reducer 可能在开发模式下被调用两次来帮你发现这类问题。
  • 动作对象里塞函数或复杂逻辑:action 应该是「发生了什么」的描述(type + 载荷),计算放进 reducer。
  • 直接改 state 返回:state.log.push(...) 后返回原对象,React 认为状态没变不重渲染;必须返回新对象。
  • 用 reducer 但动作名还是赋值思维:SET_TITLE、SET_STEP 满天飞,等于把 useState 换了个地方写;先想清楚业务动作再建模。

检查与复盘

完成下方检查题确认理解。自查清单:

  • 能画出这个流程的状态机:idle / editing / submitting / done 和每条边的触发动作
  • 知道非法操作的处理位置在 reducer 的守卫分支里,而不是每个调用点
  • 写新功能时先列动作清单(业务语言),再动手写 reducer

参考资料

动手实验
动手实验
预览
点击「运行」在隔离沙箱中预览
检查题共 3 题,提交后立即显示解析
答案由服务端判定;答错的题会自动进入复习计划。
Q1单选中等

关于 useReducer 中的 reducer 函数,下列说法正确的是?

Q2读代码中等

阅读代码(React 18+)。点击一次「执行」按钮后,页面上显示什么?

function reducer(state, action) {
  switch (action.type) {
    case 'add':
      return {
        count: state.count + action.n,
        history: [...state.history, action.n],
      };
    case 'reset':
      return { count: 0, history: [] };
    default:
      return state;
  }
}

function Counter() {
  const [state, dispatch] = useReducer(reducer, { count: 0, history: [] });

  function runAll() {
    dispatch({ type: 'add', n: 2 });
    dispatch({ type: 'add', n: 3 });
    dispatch({ type: 'reset' });
  }

  return (
    <>
      <button onClick={runAll}>执行</button>
      <p>
        count={state.count}, ops={state.history.length}
      </p>
    </>
  );
}
Q3多选简单(多选,全部选对才算正确)

关于 useReducer 与 useState 的选择,下列说法正确的有?(多选)

读完这一课了?

标记完成后,工作台会安排接下来的学习。

Command Palette

Search for a command to run...