React 基础体系 · 第 26/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。

React Reducer 深入:Action、不可变更新、初始化和状态机

useReducer 不是“另一种 useState 写法”这么简单。它把状态变化拆成三个可独立推理的部分:

  1. State:当前状态。
  2. Action:描述“发生了什么”的事件。
  3. Reducer:根据旧状态和事件计算新状态。

其核心关系可以写成:

Snext=R(Scurrent,A)S_{next} = R(S_{current}, A)

其中:

  • ScurrentS_{current} 是当前状态;
  • AA 是 action;
  • RR 是 reducer;
  • SnextS_{next} 是下一状态。

React 负责保存状态、调用 reducer,并在结果发生变化时安排重新渲染;业务代码负责定义状态结构、action 类型和状态转换规则。


一、什么时候需要 Reducer

当组件只有一个简单值时,useState 通常更直接:

const [count, setCount] = useState(0);

但状态逐渐复杂时,更新逻辑可能分散在多个事件处理器中:

const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
const [data, setData] = useState<User[] | null>(null);
const [error, setError] = useState<string | null>(null);

async function loadUsers() {
  setStatus('loading');
  setError(null);

  try {
    const response = await fetch('/api/users');
    const users = await response.json();

    setData(users);
    setStatus('success');
  } catch {
    setError('加载失败');
    setStatus('error');
  }
}

这里存在几个问题:

  • statusdataerror 之间有隐含约束;
  • 不同事件处理器可能忘记同步更新某个字段;
  • status: 'success'data 是否一定非空,类型系统无法保证;
  • 以后增加重试、取消、分页时,状态更新会继续分散。

Reducer 的价值是把这些状态变化集中到一个函数中:

type State = {
  status: 'idle' | 'loading' | 'success' | 'error';
  data: User[] | null;
  error: string | null;
};

type Action =
  | { type: 'load_started' }
  | { type: 'load_succeeded'; users: User[] }
  | { type: 'load_failed'; message: string };

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case 'load_started':
      return {
        status: 'loading',
        data: null,
        error: null,
      };

    case 'load_succeeded':
      return {
        status: 'success',
        data: action.users,
        error: null,
      };

    case 'load_failed':
      return {
        status: 'error',
        data: null,
        error: action.message,
      };
  }
}

事件处理器只负责发送事件:

dispatch({ type: 'load_started' });

于是:

  • 事件处理器描述发生了什么;
  • reducer 决定状态如何变化;
  • UI 根据状态渲染。

这是一种单向数据流,而不是让任意事件处理器直接修改多个状态变量。


二、Action 是事件,不是“下一状态”

1. Action 的定义

Action 是一个普通 JavaScript 值,通常是带有 type 字段的对象:

type Action =
  | { type: 'increment' }
  | { type: 'decrement' }
  | { type: 'set'; value: number };

type 表示事件种类,其他字段是该事件的载荷,也称为 payload:

dispatch({ type: 'set', value: 10 });

这里的 action 表示:

用户或系统发出了“把计数设置为 10”这个事件。

它不是:

dispatch({ count: 10 });

也不是:

dispatch(state => state + 1);

后者是 useState 的更新函数风格,而不是 reducer 的 action 风格。

2. Action 描述过去发生的事情

比较:

dispatch({ type: 'add_item', item });

和:

dispatch({ type: 'set_items', items: [...state.items, item] });

第一种表达的是事件:

添加了一个商品。

第二种把计算后的结果直接塞进 action,导致事件处理器必须知道当前状态和更新细节。

事件型 action 更容易:

  • 记录日志;
  • 重放事件;
  • 测试 reducer;
  • 在多个来源触发同一种状态变化;
  • 以后修改状态结构而不修改所有调用方。

但这不是绝对规则。Action 可以携带已经计算好的数据,例如后端返回的数据:

dispatch({
  type: 'request_succeeded',
  response: users,
});

关键区别在于:action 应描述输入事件,而不是偷偷承担 reducer 的全部工作。

3. Action 使用可辨识联合类型

TypeScript 中,最适合 reducer 的 action 类型通常是 discriminated union:

type Action =
  | { type: 'increment'; amount: number }
  | { type: 'decrement'; amount: number }
  | { type: 'reset' };

type 是判别字段。进入 switch 后,TypeScript 会根据它缩小类型:

function reducer(state: number, action: Action): number {
  switch (action.type) {
    case 'increment':
      return state + action.amount;

    case 'decrement':
      return state - action.amount;

    case 'reset':
      return 0;
  }
}

如果 action 类型增加了分支而 reducer 忘记处理,可以使用穷尽性检查:

function assertNever(value: never): never {
  throw new Error(`Unhandled action: ${JSON.stringify(value)}`);
}

function reducer(state: number, action: Action): number {
  switch (action.type) {
    case 'increment':
      return state + action.amount;

    case 'decrement':
      return state - action.amount;

    case 'reset':
      return 0;

    default:
      return assertNever(action);
  }
}

default 分支中的 action 只有在所有联合成员都已处理时才是 never。因此,新增 action 后,编译器会在遗漏的位置报错。

4. Action 类型应与状态转换对应

如果状态是一个异步请求状态:

type State =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: User[] }
  | { status: 'error'; message: string };

那么 action 可以这样定义:

type Action =
  | { type: 'fetch_started' }
  | { type: 'fetch_succeeded'; data: User[] }
  | { type: 'fetch_failed'; message: string };

这种写法比下面的“可选字段大杂烩”更安全:

type BadState = {
  status: 'idle' | 'loading' | 'success' | 'error';
  data?: User[];
  message?: string;
};

后者允许大量不合理状态,例如:

{
  status: 'success',
  data: undefined,
  message: '上一次错误'
}

联合类型把“状态名”和“该状态允许携带的数据”绑定起来,减少非法状态。


三、Reducer 的本质:纯函数

1. 纯函数的条件

Reducer 应满足:

R(S,A)=SR(S, A) = S'

对于相同的 SA,结果应一致,并且不能依赖函数外部可变环境。

一个纯 reducer:

function reducer(state: number, action: Action): number {
  if (action.type === 'increment') {
    return state + action.amount;
  }

  return state;
}

一个不纯的 reducer:

let sequence = 0;

function reducer(state: State, action: Action): State {
  sequence += 1;

  return {
    ...state,
    sequence,
  };
}

相同输入下,第一次调用和第二次调用会得到不同结果,因此它不是纯函数。

以下行为通常不应放在 reducer 中:

  • 发起网络请求;
  • 写入 localStorage
  • 修改 DOM;
  • 读取当前时间;
  • 生成随机数;
  • 修改模块级变量;
  • 修改传入的 stateaction

副作用应放在事件处理器、Effect、数据获取层或其他明确的副作用边界中。

2. React 为什么强调纯 reducer

React 可能在开发环境中重复调用组件、reducer 或初始化函数,以发现不纯逻辑。StrictMode 下的这类额外调用是开发期检查,不代表生产环境必然执行两次。

如果 reducer 只是计算:

function reducer(state: number, action: Action): number {
  return action.type === 'increment'
    ? state + action.amount
    : state;
}

重复调用不会改变结果。

如果 reducer 中有副作用:

function reducer(state: State, action: Action): State {
  localStorage.setItem('state', JSON.stringify(state));
  return state;
}

重复调用就可能产生重复写入,暴露出代码边界错误。

3. Reducer 不应该派发 action

下面的写法会把状态转换和调度过程混在一起:

function reducer(state: State, action: Action): State {
  if (action.type === 'login_succeeded') {
    dispatch({ type: 'load_profile' }); // 不应这样做
  }

  return state;
}

Reducer 的职责是返回下一状态,而不是触发新的事件。需要连续状态变化时,应由事件处理器、Effect 或显式的状态机流程协调:

function handleLogin() {
  dispatch({ type: 'login_started' });

  login()
    .then(user => {
      dispatch({ type: 'login_succeeded', user });
    })
    .catch(error => {
      dispatch({ type: 'login_failed', message: String(error) });
    });
}

四、不可变更新:返回新引用,而不是修改旧状态

1. 什么是不可变更新

不可变更新不是说“数据永远不能变化”,而是说:

reducer 不直接修改传入的旧状态,而是基于旧状态创建新的状态值。

错误写法:

function reducer(state: State, action: Action): State {
  if (action.type === 'rename') {
    state.user.name = action.name;
    return state;
  }

  return state;
}

这里有两个问题:

  1. state.user 被直接修改;
  2. 返回的顶层 state 仍然是原来的引用。

正确写法:

function reducer(state: State, action: Action): State {
  if (action.type === 'rename') {
    return {
      ...state,
      user: {
        ...state.user,
        name: action.name,
      },
    };
  }

  return state;
}

更新路径上的每一级对象都要创建新引用。

2. 为什么引用很重要

React 和 JavaScript 不会深度比较所有状态内容。对于状态更新是否产生新值,引用身份很重要。

若 reducer 返回原对象:

return state;

React 可以把它视为没有新的状态值,从而跳过相关更新。

若错误地修改旧对象后返回旧引用:

state.items.push(action.item);
return state;

状态内容确实变了,但引用没有变。这样可能出现:

  • 组件没有按预期重新渲染;
  • 依赖引用比较的 memo 组件继续使用旧判断;
  • 某些 Effect 不会重新运行;
  • 调试工具中的历史状态被一并改写;
  • 并发渲染时出现难以解释的共享可变数据问题。

正确写法:

return {
  ...state,
  items: [...state.items, action.item],
};

3. 嵌套对象需要逐层复制

假设状态是:

type State = {
  settings: {
    appearance: {
      theme: 'light' | 'dark';
    };
  };
};

错误:

state.settings.appearance.theme = 'dark';
return {
  ...state,
  settings: state.settings,
};

虽然顶层对象新建了,但 settings.appearance 已经被原地修改。

正确:

return {
  ...state,
  settings: {
    ...state.settings,
    appearance: {
      ...state.settings.appearance,
      theme: 'dark',
    },
  },
};

更新路径可以表示为:

state
└── settings
    └── appearance
        └── theme

只要 theme 改变,statesettingsappearance 三层引用都应更新;不在更新路径上的分支可以复用原引用。

4. 数组更新方法

常见不可变数组操作如下:

// 添加
const next = [...items, item];

// 删除
const next = items.filter(item => item.id !== id);

// 更新一个元素
const next = items.map(item =>
  item.id === id ? { ...item, completed: true } : item
);

// 插入指定位置
const next = [
  ...items.slice(0, index),
  item,
  ...items.slice(index),
];

避免在 reducer 中直接使用会修改原数组的方法:

items.push(item);
items.splice(index, 1);
items.sort();
items.reverse();

即使 sortreverse 的结果被赋给新变量,原数组仍可能已经被修改:

const sorted = [...items].sort(compare);

这里先复制再排序,才不会修改原数组。

5. Object.freeze 不是不可变更新本身

开发环境中可以使用冻结帮助发现误修改:

Object.freeze(state);

但冻结只能阻止某些直接写入,不能替代正确的更新结构,也不能自动深度冻结所有嵌套对象。生产代码不应依赖它来实现状态管理。

Immer 等库可以把“看起来可变”的写法转换为不可变结果,但 reducer 仍然必须遵守其 API 语义:

import { produce } from 'immer';

const reducer = produce((draft: State, action: Action) => {
  if (action.type === 'rename') {
    draft.user.name = action.name;
  }
});

这里修改的是 Immer draft,不是原始 React state。若没有 Immer,直接照写就是错误的。


五、useReducer 的运行模型

1. 基本 API

const [state, dispatch] = useReducer(reducer, initialArg);

可以理解为:

  • state:当前渲染使用的状态;
  • dispatch:提交 action 的函数;
  • reducer:计算下一状态的函数;
  • initialArg:初始化输入。

调用:

dispatch({ type: 'increment', amount: 1 });

并不会立即修改当前函数调用中的 state

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

  function handleClick() {
    dispatch({ type: 'increment', amount: 1 });

    console.log(count); // 仍是当前这次渲染看到的 count
  }

  // ...
}

React 会在后续更新流程中使用 action 计算下一状态,再以新状态重新调用组件函数。

因此,dispatch 后想立即读取新状态,应把依赖新状态的逻辑放到:

  • 下一次渲染中;
  • 依赖该状态的 useEffect 中;
  • 或者在 dispatch 前就计算需要的局部值。

2. Dispatch 可以连续调用

React 会处理同一事件中的多个更新。Reducer 逻辑必须能够按顺序处理 action:

dispatch({ type: 'increment', amount: 1 });
dispatch({ type: 'increment', amount: 1 });

概念上相当于:

const s1 = reducer(s0, { type: 'increment', amount: 1 });
const s2 = reducer(s1, { type: 'increment', amount: 1 });

最终结果是 s2,而不是两个 action 都基于最初的 s0 独立计算。

3. Dispatch 函数身份稳定

React 保证由 useReducer 返回的 dispatch 身份在组件生命周期内保持稳定。因此通常不需要因为 dispatch 本身变化而把它放入 Effect 依赖变化的考虑中。把它写入依赖数组也通常是安全的:

useEffect(() => {
  // ...
}, [dispatch]);

但这不意味着 reducer 可以读取组件中的任意变量。Reducer 应通过 stateaction 获得所有计算输入。

4. Reducer 的返回值必须是完整的下一状态

useState 可以传入一个局部值:

setState(previous => ({
  ...previous,
  enabled: true,
}));

Reducer 则不会自动合并对象:

function reducer(state: State, action: Action): State {
  if (action.type === 'enable') {
    return { enabled: true }; // 如果 State 还有其他字段,这是错误的
  }

  return state;
}

必须显式返回完整状态:

return {
  ...state,
  enabled: true,
};

六、初始化:初始值、惰性初始化和重置

1. 直接初始化

const [state, dispatch] = useReducer(reducer, {
  status: 'idle',
  items: [],
});

这里第二个参数是 initialArg,也就是初始状态值。

组件后续重新渲染时,React 不会因为这个对象表达式重新创建,就把状态重置为初始值。useReducer 的初始参数只用于初始化阶段。

2. 惰性初始化

当初始状态需要计算时,可以传第三个参数 init

type State = {
  count: number;
};

function init(initialCount: number): State {
  return {
    count: initialCount * 2,
  };
}

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case 'increment':
      return { count: state.count + 1 };

    case 'reset':
      return init(action.initialCount);
  }
}

type Action =
  | { type: 'increment' }
  | { type: 'reset'; initialCount: number };

function Counter({ initialCount }: { initialCount: number }) {
  const [state, dispatch] = useReducer(reducer, initialCount, init);

  return (
    <>
      <p>{state.count}</p>
      <button
        onClick={() =>
          dispatch({ type: 'reset', initialCount })
        }
      >
        重置
      </button>
    </>
  );
}

调用形式:

useReducer(reducer, initialArg, init)

初始化结果是:

init(initialArg)

而不是直接把 initialArg 当作 state。

惰性初始化适合:

  • 解析复杂初始输入;
  • 从 URL 参数计算状态;
  • 将字符串解析为结构化数据;
  • 统一“首次初始化”和“重置”逻辑。

3. 初始化函数也必须纯

错误示例:

function init(userId: string): State {
  localStorage.setItem('last-user', userId);
  return loadStateFromSomewhere(); // 还可能包含异步行为
}

初始化函数可能在开发检查中被重复调用,也必须是同步、纯、可重复计算的函数。需要从网络加载数据时,应先以 idleloading 状态初始化,再在 Effect 或数据获取层中发起请求。

4. Props 变化不会自动重新初始化

下面的代码不会在 initialCount 改变时自动重置:

function Counter({ initialCount }: { initialCount: number }) {
  const [count, dispatch] = useReducer(
    reducer,
    initialCount,
    init,
  );

  // initialCount 后续变化时,已有 state 不会自动替换
  return null;
}

原因是:initialCount 只在 Hook 初始化时作为 initialArg 使用。

如果业务语义是“用户点击按钮后重置”,应显式派发 action:

dispatch({
  type: 'reset',
  initialCount,
});

如果业务语义是“切换到另一个实体时整个组件状态重新开始”,可以使用 key

<Editor key={documentId} documentId={documentId} />

key 改变时,React 会把它视为新的组件实例,从而重新初始化 Hook。这个行为会丢弃旧组件的本地状态,应确认这正是业务需要,而不是用来掩盖状态同步设计问题。

5. SSR 和 hydration 下的初始化约束

在服务端渲染场景中,服务端生成 HTML,客户端随后进行 hydration。若初始化结果依赖不稳定输入,可能造成服务端和客户端首屏不一致:

function init() {
  return {
    width: window.innerWidth,
  };
}

服务端没有 window,这段代码还会直接失败。即使通过条件判断,客户端首屏和服务端 HTML 也可能不同。

更稳妥的方式是:

const [state, dispatch] = useReducer(reducer, {
  width: null,
});

然后在客户端 Effect 中读取浏览器环境:

useEffect(() => {
  dispatch({
    type: 'width_detected',
    width: window.innerWidth,
  });
}, []);

这会先渲染确定性的初始内容,再在客户端更新浏览器相关状态。


七、用状态机理解 Reducer

1. 状态机的组成

状态机是一种描述系统状态转换的模型。一个简化的确定性状态机可以表示为:

M=(S,A,δ)M = (S, A, \delta)

其中:

  • SS:所有可能状态的集合;
  • AA:所有可能 action 的集合;
  • δ:S×AS\delta: S \times A \rightarrow S:状态转换函数。

在 React 中:

reducer(state, action)

就是转换函数 δ\delta

例如加载数据时:

idle --FETCH--> loading
loading --SUCCESS(data)--> success(data)
loading --FAIL(message)--> error(message)
error --RETRY--> loading
success --RESET--> idle

用 Mermaid 表示:

stateDiagram-v2
    [*] --> idle
    idle --> loading: fetch
    loading --> success: success(data)
    loading --> error: failure(message)
    error --> loading: retry
    success --> loading: refresh
    success --> idle: reset
    error --> idle: reset

这里的关键不是画图,而是明确:

  • 当前状态允许哪些事件;
  • 每个事件会转移到哪个状态;
  • 哪些状态组合根本不允许出现。

2. 完整状态机示例

'use client';

import { useEffect, useReducer } from 'react';

type User = {
  id: string;
  name: string;
};

type State =
  | { status: 'idle'; requestId: number; data: User[] }
  | { status: 'loading'; requestId: number; data: User[] }
  | { status: 'success'; requestId: number; data: User[] }
  | { status: 'error'; requestId: number; data: User[]; message: string };

type Action =
  | { type: 'fetch_started'; requestId: number }
  | {
      type: 'fetch_succeeded';
      requestId: number;
      data: User[];
    }
  | {
      type: 'fetch_failed';
      requestId: number;
      message: string;
    }
  | { type: 'reset' };

const initialState: State = {
  status: 'idle',
  requestId: 0,
  data: [],
};

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case 'fetch_started':
      return {
        status: 'loading',
        requestId: action.requestId,
        data: state.data,
      };

    case 'fetch_succeeded':
      // 忽略过期请求的结果
      if (action.requestId !== state.requestId) {
        return state;
      }

      return {
        status: 'success',
        requestId: state.requestId,
        data: action.data,
      };

    case 'fetch_failed':
      // 忽略过期请求的错误
      if (action.requestId !== state.requestId) {
        return state;
      }

      return {
        status: 'error',
        requestId: state.requestId,
        data: state.data,
        message: action.message,
      };

    case 'reset':
      return initialState;

    default:
      return assertNever(action);
  }
}

function assertNever(value: never): never {
  throw new Error(`Unhandled action: ${JSON.stringify(value)}`);
}

export function UserList() {
  const [state, dispatch] = useReducer(reducer, initialState);

  useEffect(() => {
    const requestId = state.requestId + 1;
    const controller = new AbortController();

    dispatch({ type: 'fetch_started', requestId });

    fetch('/api/users', { signal: controller.signal })
      .then(response => {
        if (!response.ok) {
          throw new Error(`HTTP ${response.status}`);
        }

        return response.json() as Promise<User[]>;
      })
      .then(data => {
        dispatch({
          type: 'fetch_succeeded',
          requestId,
          data,
        });
      })
      .catch(error => {
        if (error instanceof DOMException && error.name === 'AbortError') {
          return;
        }

        dispatch({
          type: 'fetch_failed',
          requestId,
          message: error instanceof Error ? error.message : '未知错误',
        });
      });

    return () => {
      controller.abort();
    };
  }, []);

  switch (state.status) {
    case 'idle':
      return <p>尚未加载</p>;

    case 'loading':
      return <p>加载中……</p>;

    case 'success':
      return (
        <ul>
          {state.data.map(user => (
            <li key={user.id}>{user.name}</li>
          ))}
        </ul>
      );

    case 'error':
      return (
        <>
          <p role="alert">加载失败:{state.message}</p>
          <button
            onClick={() =>
              dispatch({
                type: 'fetch_started',
                requestId: state.requestId + 1,
              })
            }
          >
            重试
          </button>
        </>
      );

    default:
      return assertNever(state);
  }
}

这个例子有几个重要机制。

状态与数据被绑定

success 状态中,TypeScript 知道 data 存在;在 error 状态中,message 存在。UI 不需要通过多个可选字段猜测当前状态。

过期请求不会覆盖新请求

假设请求 A 先发出,请求 B 后发出,但 B 先返回:

A started, requestId = 1
B started, requestId = 2
B succeeded, requestId = 2
A succeeded, requestId = 1

当 A 的结果到达时,当前状态的 requestId 已经是 2,因此 reducer 返回原状态:

if (action.requestId !== state.requestId) {
  return state;
}

这条判断避免了旧请求覆盖新请求。

Abort 和 requestId 解决不同问题

AbortController 尝试取消底层请求;但取消并不应被当作绝对可靠的业务一致性机制。请求可能已经完成、响应可能已经进入队列,或者数据源不支持取消。

requestId 是逻辑层的过期判断,即使旧响应最终到达,也不会改变当前状态。

Effect 负责副作用,Reducer 负责状态转换

fetchAbortController 和 Promise 都在 Effect 中;Reducer 只处理同步输入:

fetch started
fetch succeeded
fetch failed

这种边界使 reducer 可以脱离 React 单独测试。


八、Reducer 与异步流程:不要把 Promise 放进状态转换

Reducer 必须是同步函数。下面的写法不成立:

async function reducer(state: State, action: Action): Promise<State> {
  const data = await fetch('/api/data');
  return {
    status: 'success',
    data,
  };
}

useReducer 期望 reducer 返回下一状态值,而不是 Promise。异步流程应拆为:

事件处理器或 Effect
    |
    | 发出 started
    v
Reducer: loading
    |
    | 网络请求完成后 dispatch succeeded/failed
    v
Reducer: success 或 error

如果使用 React 19 的表单 Action、Transition 或框架提供的数据操作能力,也不应因此改变 reducer 的基本语义:异步操作仍然应位于相应的副作用或服务端操作边界中,reducer 只接收已经发生的事件和结果。


九、状态机中的非法转换

一个 reducer 不仅要描述“正常路径”,还要决定非法 action 如何处理。

例如:

type State = 'idle' | 'loading' | 'success';

type Action =
  | { type: 'fetch' }
  | { type: 'success' }
  | { type: 'reset' };

如果当前状态是 idle,收到 success 是否允许?

一种严格实现是抛错:

function reducer(state: State, action: Action): State {
  switch (state) {
    case 'idle':
      if (action.type === 'fetch') return 'loading';
      if (action.type === 'reset') return 'idle';
      throw new Error(`Invalid action ${action.type} in state ${state}`);

    case 'loading':
      if (action.type === 'success') return 'success';
      if (action.type === 'reset') return 'idle';
      throw new Error(`Invalid action ${action.type} in state ${state}`);

    case 'success':
      if (action.type === 'reset') return 'idle';
      if (action.type === 'fetch') return 'loading';
      throw new Error(`Invalid action ${action.type} in state ${state}`);
  }
}

另一种实现是忽略无效 action:

if (action.type === 'success' && state !== 'loading') {
  return state;
}

两者取舍取决于场景:

  • 用户操作可能产生重复点击时,忽略重复事件通常更稳健;
  • 内部协议错误或程序员遗漏状态转换时,开发环境抛错更容易诊断;
  • 生产环境可以记录错误并回退到安全状态,但不应静默吞掉所有异常。

重要的是明确选择,而不是让无效转换因为某个 default: return state 被无意掩盖。


十、不可变更新与结构共享

不可变更新并不意味着每次更新都要深复制整个状态树。

假设状态:

type State = {
  user: User;
  preferences: Preferences;
  notifications: Notification[];
};

只修改 user.name

return {
  ...state,
  user: {
    ...state.user,
    name: action.name,
  },
};

此时:

nextState !== state;                 // true
nextState.user !== state.user;       // true
nextState.preferences === state.preferences; // true
nextState.notifications === state.notifications; // true

这叫结构共享:

  • 更新路径创建新对象;
  • 未改变的分支复用旧引用;
  • 依赖未改变分支的组件可以利用引用稳定性减少工作。

但要注意,引用稳定不是 React 自动保证的业务优化。若每次 reducer 都无条件重建所有嵌套对象:

return {
  ...state,
  user: { ...state.user },
  preferences: { ...state.preferences },
  notifications: [...state.notifications],
};

即使实际只改了一个字段,也会让所有引用都变化,可能导致不必要的子组件更新。


十一、useReduceruseState 与外部状态管理的边界

1. useState 适合简单局部状态

例如:

const [isOpen, setIsOpen] = useState(false);

如果更新规则只有一两个简单操作,Reducer 会增加类型和样板代码,未必更好。

2. useReducer 适合复杂但局部的状态逻辑

典型场景:

  • 多步骤表单;
  • 编辑器;
  • 请求生命周期;
  • 购物车;
  • 复杂弹窗流程;
  • 一个组件内多个事件共同影响同一状态。

Reducer 仍然是组件本地状态。它不会自动让多个页面共享状态,也不会替代服务端缓存、数据库或全局状态库。

3. Context 可以分发 reducer 状态,但不是 reducer 的一部分

可以通过 Context 把 statedispatch 提供给深层组件:

const StateContext = createContext<State | null>(null);
const DispatchContext = createContext<React.Dispatch<Action> | null>(null);

但 Context 只负责传递值;状态转换仍由 reducer 完成。若整个应用都依赖一个巨大 Context,任何状态变化都可能导致大量消费者重新渲染,需要进一步拆分 Context 或使用更适合的外部状态方案。


十二、客户端与服务端边界

1. useReducer 是客户端交互 Hook

在采用 React Server Components 的框架中,使用 useReducer 的组件通常必须是客户端组件。例如 Next.js App Router 中:

'use client';

import { useReducer } from 'react';

'use client' 表示该模块及其客户端依赖需要进入客户端边界。它不是 React Core 的通用运行时指令,而是由支持 Server Components 的框架处理的模块边界标记。

服务端组件不能直接使用 useReducer 来维持浏览器交互状态。典型分工是:

服务端组件:获取或准备初始数据
    |
    v
客户端组件:useReducer 管理交互状态

2. 服务端 Action 与 reducer action 不是同一个概念

某些 React 19 生态和框架支持服务端函数、表单 Action 或 Server Action。它们可能也被称为“action”,但含义不同:

  • reducer action:传给本地 reducer 的普通值;
  • 服务端 Action:触发服务端执行的异步操作或框架协议。

例如:

dispatch({ type: 'save_started' });

这是本地 reducer action,不会自动执行服务端代码。

如果保存操作调用服务端接口:

async function handleSave() {
  dispatch({ type: 'save_started' });

  try {
    const result = await saveOnServer(formData);

    dispatch({
      type: 'save_succeeded',
      result,
    });
  } catch (error) {
    dispatch({
      type: 'save_failed',
      message: error instanceof Error ? error.message : '保存失败',
    });
  }
}

本地 reducer 仍只负责 save_startedsave_succeededsave_failed 这些状态转换。

3. 初始状态必须可序列化且稳定

服务端向客户端传递初始数据时,通常需要符合框架允许的可传递值范围。不要把以下对象直接当作跨边界状态输入:

  • 数据库连接;
  • 请求对象;
  • 类实例;
  • 函数;
  • 含有循环引用的对象;
  • 不受框架支持的特殊对象。

将服务端数据转换为客户端需要的普通对象、数组、字符串、数字和布尔值,通常更容易保证 hydration 和类型安全。


十三、完整可测试的 Reducer 设计

由于 reducer 是纯函数,可以不依赖 React 测试:

type State = {
  count: number;
};

type Action =
  | { type: 'increment'; amount: number }
  | { type: 'reset' };

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case 'increment':
      return {
        count: state.count + action.amount,
      };

    case 'reset':
      return {
        count: 0,
      };

    default:
      return assertNever(action);
  }
}

function assertNever(value: never): never {
  throw new Error(`Unhandled action: ${JSON.stringify(value)}`);
}

测试可以直接验证状态转换:

const initialState = { count: 0 };

console.assert(
  reducer(initialState, { type: 'increment', amount: 2 }).count === 2,
);

console.assert(
  reducer({ count: 2 }, { type: 'reset' }).count === 0,
);

还可以验证不可变性:

const state = { count: 0 };
const next = reducer(state, { type: 'increment', amount: 1 });

console.assert(state.count === 0);
console.assert(next.count === 1);
console.assert(next !== state);

对于对象和数组,应进一步验证未修改分支是否保持结构共享:

const state = {
  user: { id: '1', name: 'A' },
  settings: { theme: 'light' as const },
};

const next = {
  ...state,
  user: {
    ...state.user,
    name: 'B',
  },
};

console.assert(next !== state);
console.assert(next.user !== state.user);
console.assert(next.settings === state.settings);

十四、常见失败表现与诊断路径

1. 派发后读取到旧状态

dispatch({ type: 'increment', amount: 1 });
console.log(state.count);

这是因为当前组件函数代表一次渲染,state 是该次渲染的快照。诊断时应检查:

  • 是否把“派发”误认为同步赋值;
  • 是否需要在 Effect 中响应状态变化;
  • 是否应该在派发前保存计算所需的值。

2. UI 不更新

优先检查 reducer 是否直接修改旧对象:

state.items.push(item);
return state;

以及是否错误地返回了相同引用:

state.user.name = 'new';
return state;

修复为沿更新路径复制对象和数组。

3. 状态出现互相矛盾的组合

例如:

{
  status: 'loading',
  error: '旧错误',
  data: []
}

这通常说明状态字段没有被集中管理,或者 action 转换没有清理不再有效的字段。使用联合状态类型可以在编译期减少这类问题。

4. 严格模式下出现重复请求或重复写入

如果请求写在 reducer 或初始化函数中,开发环境的重复调用会暴露问题。将请求移动到 Effect 或事件处理器,并让清理函数取消请求:

useEffect(() => {
  const controller = new AbortController();

  fetch('/api/data', { signal: controller.signal });

  return () => controller.abort();
}, []);

若仍担心旧请求覆盖新请求,再增加序列号或请求标识。

5. 重置按钮没有恢复预期状态

检查重置 action 是否真的返回了完整初始状态:

case 'reset':
  return initialState;

如果初始状态依赖输入参数,应使用:

case 'reset':
  return init(action.initialArg);

不要只修改一个字段,却保留了旧的错误、请求标识或编辑数据。

6. reducer 返回 undefined

TypeScript 可以帮助发现很多问题,但若 reducer 的某个分支没有返回值,运行时仍可能产生错误:

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case 'increment':
      return state + 1;
  }
}

应通过联合类型和穷尽性检查覆盖所有 action。若确实需要忽略 action,应明确:

return state;

十五、如何推导一个可靠的 Reducer

可以按以下顺序建立模型,而不是先写 switch

第一步:列出业务上互斥的状态

例如请求状态不是三个独立变量,而是:

idle
loading
success(data)
error(message)

第二步:列出外部事件

fetch_started
fetch_succeeded(data)
fetch_failed(message)
reset

第三步:建立转换表

当前状态 Action 下一状态
idle fetch_started loading
loading fetch_succeeded success(data)
loading fetch_failed error(message)
error fetch_started loading
success reset idle

第四步:明确非法转换

例如 idle + fetch_succeeded

  • 忽略;
  • 抛出错误;
  • 转换到错误状态。

必须根据业务语义选择。

第五步:实现纯 reducer

只让 reducer 处理:

旧状态 + action -> 新状态

不在其中调用网络、写缓存或读取浏览器环境。

第六步:把副作用放在外层协调

点击/Effect
  -> dispatch(started)
  -> 执行异步操作
  -> dispatch(succeeded 或 failed)

第七步:验证不变量

例如:

  • success 状态必须有 data
  • error 状态必须有 message
  • 过期请求不能覆盖当前请求;
  • reducer 不修改旧状态;
  • 无效 action 不会制造非法状态。

Reducer 的质量不在于 switch 写得多短,而在于它是否把这些不变量明确编码出来。


十六、最后的判断标准

一个适合生产使用的 React reducer,通常可以回答以下问题:

  1. 每个 action 表示什么外部事件?
  2. 每种状态允许携带哪些数据?
  3. 相同的 state 和 action 是否总能得到相同结果?
  4. reducer 是否修改了旧对象、旧数组或 action?
  5. 更新路径上的引用是否正确创建?
  6. 初始状态是直接值还是惰性初始化?
  7. Props 变化是否真的应该触发重置?
  8. 异步请求的开始、成功、失败和取消如何表示?
  9. 旧请求返回时,如何避免覆盖新请求?
  10. 哪些 action 在当前状态下是非法的?
  11. 组件是在客户端运行,还是处于服务端组件边界?
  12. reducer 是否可以脱离 React 直接测试?

当这些问题都有明确答案时,useReducer 就不只是把多个 setState 合并起来,而是形成了一套可验证的状态转换系统:

事件 Action纯函数 Reducer不可变的新 StateReact 重新渲染\text{事件 Action} \rightarrow \text{纯函数 Reducer} \rightarrow \text{不可变的新 State} \rightarrow \text{React 重新渲染}

这条数据流也是 React Reducer、不可变更新、初始化机制和状态机之间的共同核心。


系列导航与关联阅读

官方资料

本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。