React 基础体系 · 第 26/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。
React Reducer 深入:Action、不可变更新、初始化和状态机
useReducer 不是“另一种 useState 写法”这么简单。它把状态变化拆成三个可独立推理的部分:
- State:当前状态。
- Action:描述“发生了什么”的事件。
- Reducer:根据旧状态和事件计算新状态。
其核心关系可以写成:
其中:
- 是当前状态;
- 是 action;
- 是 reducer;
- 是下一状态。
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');
}
}
这里存在几个问题:
status、data、error之间有隐含约束;- 不同事件处理器可能忘记同步更新某个字段;
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 应满足:
对于相同的 S 和 A,结果应一致,并且不能依赖函数外部可变环境。
一个纯 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;
- 读取当前时间;
- 生成随机数;
- 修改模块级变量;
- 修改传入的
state或action。
副作用应放在事件处理器、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;
}
这里有两个问题:
state.user被直接修改;- 返回的顶层
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 改变,state、settings、appearance 三层引用都应更新;不在更新路径上的分支可以复用原引用。
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();
即使 sort 或 reverse 的结果被赋给新变量,原数组仍可能已经被修改:
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 应通过 state 和 action 获得所有计算输入。
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(); // 还可能包含异步行为
}
初始化函数可能在开发检查中被重复调用,也必须是同步、纯、可重复计算的函数。需要从网络加载数据时,应先以 idle 或 loading 状态初始化,再在 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. 状态机的组成
状态机是一种描述系统状态转换的模型。一个简化的确定性状态机可以表示为:
其中:
- :所有可能状态的集合;
- :所有可能 action 的集合;
- :状态转换函数。
在 React 中:
reducer(state, action)
就是转换函数 。
例如加载数据时:
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 负责状态转换
fetch、AbortController 和 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],
};
即使实际只改了一个字段,也会让所有引用都变化,可能导致不必要的子组件更新。
十一、useReducer、useState 与外部状态管理的边界
1. useState 适合简单局部状态
例如:
const [isOpen, setIsOpen] = useState(false);
如果更新规则只有一两个简单操作,Reducer 会增加类型和样板代码,未必更好。
2. useReducer 适合复杂但局部的状态逻辑
典型场景:
- 多步骤表单;
- 编辑器;
- 请求生命周期;
- 购物车;
- 复杂弹窗流程;
- 一个组件内多个事件共同影响同一状态。
Reducer 仍然是组件本地状态。它不会自动让多个页面共享状态,也不会替代服务端缓存、数据库或全局状态库。
3. Context 可以分发 reducer 状态,但不是 reducer 的一部分
可以通过 Context 把 state 和 dispatch 提供给深层组件:
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_started、save_succeeded、save_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,通常可以回答以下问题:
- 每个 action 表示什么外部事件?
- 每种状态允许携带哪些数据?
- 相同的 state 和 action 是否总能得到相同结果?
- reducer 是否修改了旧对象、旧数组或 action?
- 更新路径上的引用是否正确创建?
- 初始状态是直接值还是惰性初始化?
- Props 变化是否真的应该触发重置?
- 异步请求的开始、成功、失败和取消如何表示?
- 旧请求返回时,如何避免覆盖新请求?
- 哪些 action 在当前状态下是非法的?
- 组件是在客户端运行,还是处于服务端组件边界?
- reducer 是否可以脱离 React 直接测试?
当这些问题都有明确答案时,useReducer 就不只是把多个 setState 合并起来,而是形成了一套可验证的状态转换系统:
这条数据流也是 React Reducer、不可变更新、初始化机制和状态机之间的共同核心。
系列导航与关联阅读
- 系列入口:React 完整学习路线:从渲染与 Hooks 到服务端组件和生产架构
- 上一篇:React 状态建模:最小状态、派生值、归一化和所有权
- 下一篇:React 自定义 Hook:契约、组合、副作用、稳定性和测试
官方资料
本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论