React(十七)Redux Toolkit:React 19 项目中的状态管理实战指南
前言:为什么要学习 Redux?
在 React 19 项目中,随着组件数量变多、页面层级变深,我们会遇到一个越来越棘手的问题:状态(state)到底该放在哪里?
- 放得太低,多个组件需要共享时就要层层传递 props,代码变得非常啰嗦。
- 放得太高,组件关系复杂后又容易牵一发而动全身,数据流变得难以追踪。
Redux 就是解决这类问题的主流方案之一。它的目标不是替代 React 自己的 state,而是为全局共享、跨组件通信提供一套清晰、可预测的数据流规范。而 Redux Toolkit(RTK)则是官方推荐的“最佳实践工具集”,帮我们在 React 19 中少写模板代码、少踩坑。
本节课将从“为什么需要 Redux”讲起,到 RTK 的实战用法,再到 React 19 并发特性下的注意事项,帮你建立一套完整的状态管理认知。
模块一:核心定义——理解 Redux 的设计哲学
学习目标
- 能用自己的话说出前端状态管理的痛点。
- 理解 Redux 的三大核心原则,并举例说明。
- 区分 Redux 与 Redux Toolkit 的关系,知道 RTK 的优势。
1.1 前端状态管理的痛点:从“传纸条”说起
想象一下,在一个班级里,老师想通知全班同学明天交作业。如果班里只有 5 个人,老师直接喊一嗓子就解决了。但如果班里有 50 个人、老师坐在教室最后一排,逐个传话就会非常麻烦:
- 同学 A 传给同学 B,B 传给 C,C 再传给 D……消息越传越慢。
- 中间有人没听清,消息可能失真。
- 如果老师改主意了,还得重新传一遍。
React 里通过 props 层层传递状态,就像这个“传纸条”的过程。组件少的时候没问题,组件多了就会遇到:** prop drilling(属性钻取)**。
App 组件 → Layout 组件 → Header 组件 → UserInfo 组件
用户状态 用户状态 用户状态
每一层组件其实都不需要这个状态,但为了传给最下层的组件,它们都必须“经手”一次。代码冗余、维护困难、改一处动全身。
Redux 的解决思路是:在教室正中间放一块公告板。老师把作业要求写上去,所有同学自己抬头看。消息只有一份,所有人都能看到最新版本,不会出现层层传话的问题。
这块“公告板”就是 Redux 中的 Store(仓库)。
1.2 Redux 在 React 19 项目中的定位
在 React 19 中,状态管理并不是“一定要用 Redux”。我们仍然有这些选择:
| 方案 | 适用场景 | 学习成本 |
|---|---|---|
| 组件 state(useState) | 单个组件内部状态,不需要共享 | 低 |
| Context API | 跨组件共享只读数据,更新较少 | 中 |
| Redux / RTK | 多组件共享、状态变化频繁、需要可预测的数据流 | 中 |
Redux 的核心价值:
- 统一的数据仓库:所有全局状态集中管理,不再依赖 props 层层传递。
- 可预测的状态变化:状态如何改变,通过纯函数和明确的 action 来描述,便于调试和回溯。
- 强大的调试生态:Redux DevTools 可以查看每一次状态变化,像“时光机”一样回放。
一句话总结:Redux 适合管理“多个组件都需要读写、变化规律复杂”的全局状态;局部的、组件独享的状态仍然用 useState 即可。
1.3 Redux 的三大核心原则
Redux 的设计理念可以总结为三条原则。我们用生活化的例子来理解它们。
原则一:单一数据源(Single Source of Truth)
定义:整个应用的全部全局状态都保存在一个对象树里,这个对象树存在于唯一的 Store 中。
类比:就像一家银行只有一个总账本。无论你通过哪个窗口查询余额,看到的都是同一个账本。不会出现 A 窗口说你有 1000 元,B 窗口说你只有 800 元的情况。
// 全局状态树只有一份
{
user: { name: "张三", id: 123 },
todos: [
{ id: 1, text: "学习 Redux", completed: false }
],
cart: { items: [], total: 0 }
}
好处:调试时一目了然,状态来源清晰,不会出现多个地方“各存一份”导致的数据不一致。
原则二:状态是只读的(State is Read-Only)
定义:唯一改变状态的方法是触发一个 action(动作)。你不能直接修改 Store 里的状态。
类比:银行的存款余额不能由顾客直接改写。你想取钱,必须填写一张取款单(action),银行柜员(reducer)审核后,才在总账本上修改余额。
// ❌ 错误:直接修改状态
store.user.name = "李四";
// ✅ 正确:通过 action 描述“我想做什么”
dispatch({ type: "user/changeName", payload: "李四" });
好处:所有的状态变化都被记录成“动作”,方便追踪、回放和排查 bug。
原则三:使用纯函数修改状态(Changes are Made with Pure Functions)
定义:描述状态如何变化的函数叫 reducer,它必须是纯函数。同样的输入,永远得到同样的输出;并且不会修改传入的参数,而是返回新的状态对象。
类比:自动售货机。你按同样的按钮、投入同样的钱,每次掉出来的饮料都是一样的。售货机不会偷偷改你口袋里的钱,只是根据你的输入给出对应的输出。
// ✅ 纯函数 reducer:不修改原状态,返回新状态
function counterReducer(state = 0, action) {
switch (action.type) {
case "counter/increment":
return state + 1; // 返回新值,不是修改原值
case "counter/decrement":
return state - 1;
default:
return state; // 未知 action 返回原状态
}
}
// ❌ 非纯函数:直接修改传入的参数
function badReducer(state, action) {
state.value++; // 修改了原状态,严禁这样做!
return state;
}
好处:纯函数让测试变得简单,也让 Redux DevTools 能准确记录和回放状态变化。
1.4 Redux Toolkit:官方推荐的“最佳实践工具集”
1.4.1 原生 Redux 的问题
在没有 RTK 的时代,写一个 Redux 应用需要手写很多样板代码:
// 原生 Redux 的手写 reducer
function todoReducer(state = [], action) {
switch (action.type) {
case "ADD_TODO":
return [...state, action.payload];
case "REMOVE_TODO":
return state.filter((item) => item.id !== action.payload);
default:
return state;
}
}
// 手动创建 action creator
function addTodo(text) {
return { type: "ADD_TODO", payload: text };
}
// 手动配置 store,处理中间件
const store = createStore(
todoReducer,
window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__(),
);
对于初学者来说,switch-case、action creator、手动配置 DevTools 都是容易出错的地方。而且原生 Redux 默认不处理“可变数据”,一旦不小心直接修改了 state,就可能出现 bug。
1.4.2 RTK 带来了什么改进?
Redux Toolkit(RTK)是 Redux 官方团队推出的工具集,目标是:让 Redux 写起来更简单、更不容易出错。它封装了原生 Redux 中的最佳实践,主要改进有:
| 原生 Redux 痛点 | RTK 的解决方案 |
|---|---|
| 手动写 action creator 和 switch-case | createSlice 自动生成 action 和 reducer |
| 手动配置中间件和 DevTools | configureStore 自动集成 |
| 容易误改 state | 内置 Immer,允许“看起来可变”的写法,实际生成新对象 |
| 异步逻辑分散难管理 | createAsyncThunk 统一管理异步 action |
| 样板代码多 | 代码量大幅减少,结构更清晰 |
1.4.3 核心概念区分:Redux、React-Redux、RTK
很多同学初学时容易混淆这几个概念,下面用一句话说明:
- Redux:核心状态管理库,提供 Store、reducer、dispatch 等基础能力。
- React-Redux:让 Redux 能和 React 组件“连接”的桥梁库,提供
Provider、useSelector、useDispatch等 Hook。 - Redux Toolkit(RTK):Redux 官方推荐的工具集,简化 Redux 开发流程,包含
createSlice、configureStore、createAsyncThunk等 API。
在 React 19 项目中,标准组合是:Redux + React-Redux + Redux Toolkit。RTK 负责简化代码,React-Redux 负责把 Redux 接到 React 组件里。
1.5 模块一小结
本模块我们学习了:
- 前端状态管理的核心痛点是“传纸条”式的 prop drilling,Redux 通过统一 Store 解决。
- Redux 三大原则:单一数据源、状态只读、纯函数修改状态。
- RTK 是 Redux 官方推荐的工具集,能大幅减少样板代码,降低出错概率。
课后思考
- 你能不能举一个自己项目中“状态需要跨组件共享”的例子?
- 为什么说 reducer 必须是纯函数?如果直接修改 state 会有什么隐患?
- RTK 和原生 Redux 最本质的区别是什么?
模块二:实战使用——从零搭建 RTK 项目
学习目标
- 能在 React 19 项目中从零搭建 RTK 环境。
- 掌握
useSelector和useDispatch的使用方法。- 能用
createAsyncThunk完成带加载状态和错误处理的异步请求。
2.1 环境搭建:安装依赖
打开终端,进入你的 React 19 项目根目录,执行以下命令:
# 安装 Redux Toolkit 和 React-Redux
npm install @reduxjs/toolkit react-redux
安装完成后,你会在 package.json 的 dependencies 中看到这两个依赖:
{
"dependencies": {
"react": "^18.0.0",
"react-dom": "^18.0.0",
"@reduxjs/toolkit": "^2.0.0",
"react-redux": "^9.0.0"
}
}
注意:React 19 目前使用 React 18 的 API 即可,上述版本组合是当前课程环境的稳定搭配。后续 React 19 正式发布后,React-Redux 会自动兼容,写法不变。
2.2 创建 Slice:状态 + 同步 reducer + 自动 action
Slice 是 RTK 中最重要的概念之一,一个 Slice 通常对应一个业务模块的状态。它把“状态、reducer、action”集中写在一个文件里,结构非常清晰。
下面是 计数器 Slice 的完整代码,每一行都添加了注释,方便教学使用:
// src/store/counterSlice.js
// 1. 从 Redux Toolkit 中导入 createSlice
import { createSlice } from "@reduxjs/toolkit";
// 2. 定义初始状态
const initialState = {
value: 0, // 计数器的当前值
};
// 3. 使用 createSlice 创建 Slice
const counterSlice = createSlice({
name: "counter", // Slice 的名称,用于生成 action 的 type 前缀
initialState, // 初始状态
reducers: {
// 同步的 reducer 函数集合
// 4. increment:让 value 加 1
increment: (state) => {
// RTK 内部集成了 Immer,这里可以写“看起来可变”的代码
state.value += 1;
},
// 5. decrement:让 value 减 1
decrement: (state) => {
state.value -= 1;
},
// 6. incrementByAmount:根据传入的 payload 增加指定数值
incrementByAmount: (state, action) => {
state.value += action.payload;
},
},
});
// 7. 导出自动生成的 action creator,组件中通过 dispatch 调用
export const { increment, decrement, incrementByAmount } = counterSlice.actions;
// 8. 导出 reducer,用于配置 Store
export default counterSlice.reducer;
代码关键点解析
createSlice会根据reducers里的函数名,自动生成同名的 action creator。例如increment函数会生成increment()这个 action。- RTK 内部集成了 Immer,允许你在 reducer 中写
state.value += 1,看起来是修改了 state,但底层会自动生成不可变的新状态对象。 - 最后导出
actions和reducer,分别给组件和 Store 使用。
2.3 配置 Store:应用唯一的“公告板”
Store 是 Redux 应用的核心,整个应用只有一份 Store。在 RTK 中,我们使用 configureStore 来创建它。
// src/store/index.js
// 1. 导入 configureStore
import { configureStore } from "@reduxjs/toolkit";
// 2. 导入各个 Slice 的 reducer
import counterReducer from "./counterSlice";
// 3. 创建 Store
const store = configureStore({
reducer: {
// 4. 这里的 key 会决定组件中访问状态的路径
// 例如:state.counter.value
counter: counterReducer,
},
});
// 5. 导出 store,供 Provider 注入
export default store;
configureStore 会自动完成原生 Redux 中需要手动做的事:
- 集成 Redux DevTools 扩展;
- 默认启用常用的中间件(如处理异步的 thunk 中间件);
- 自动合并 reducer。
2.4 注入 Provider:让 React 组件连接 Redux
React-Redux 提供了 Provider 组件,我们需要把 React 应用包裹在 Provider 中,并传入 Store。
// src/main.jsx(或 src/index.js,取决于你的项目模板)
import React from "react";
import ReactDOM from "react-dom/client";
import { Provider } from "react-redux"; // 导入 Provider
import App from "./App";
import store from "./store"; // 导入刚才创建的 store
// 创建 React 19 的根节点
const root = ReactDOM.createRoot(document.getElementById("root"));
// 用 Provider 包裹整个应用,让所有组件都能访问 Redux Store
root.render(
<Provider store={store}>
<App />
</Provider>,
);
这一步是“把公告板挂到学校大厅”的关键。只有包裹了 Provider,下面的组件才能通过
useSelector读取状态、通过useDispatch派发 action。
2.5 在组件中读取和更新状态:useSelector 与 useDispatch
2.5.1 计数器组件示例
// src/components/Counter.jsx
import React from "react";
import { useSelector, useDispatch } from "react-redux"; // 导入两个核心 Hook
import { increment, decrement, incrementByAmount } from "../store/counterSlice";
function Counter() {
// 1. useSelector:从 Store 中读取状态
// 参数是一个函数,state 就是整个 Redux 状态树
const count = useSelector((state) => state.counter.value);
// 2. useDispatch:获取 dispatch 函数,用于派发 action
const dispatch = useDispatch();
return (
<div style={{ padding: 20, textAlign: "center" }}>
<h2>当前计数:{count}</h2>
{/* 3. 点击按钮时,调用 dispatch 并传入 action creator */}
<button onClick={() => dispatch(decrement())}> -1 </button>
<button onClick={() => dispatch(increment())}> +1 </button>
<button onClick={() => dispatch(incrementByAmount(5))}> +5 </button>
</div>
);
}
export default Counter;
2.5.2 状态流转流程图(文字版)
┌────────────────────────────────────────────────────────────┐
│ Redux 状态流转示意图 │
└────────────────────────────────────────────────────────────┘
用户点击按钮
│
▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ React 组件 │ ───▶ │ dispatch() │ ───▶ │ action │
│ Counter.jsx │ │ 派发动作 │ │ 描述要做什么 │
└──────────────┘ └──────────────┘ └──────┬───────┘
│
▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ 组件重新渲染 │ ◀─── │ Store 更新 │ ◀─── │ reducer │
│ useSelector │ │ 保存新状态 │ │ 纯函数计算新状态│
└──────────────┘ └──────────────┘ └──────────────┘
流程说明:
- 用户在组件中点击按钮,触发
dispatch(action)。 - Redux 把 action 传给对应的 reducer。
- reducer 是纯函数,根据旧状态和 action 计算出新状态。
- Store 更新为新的状态树。
- 使用了
useSelector读取该状态的组件,会接收到更新并重新渲染。
2.6 综合案例:带 TodoList 的计数器
下面是一个更贴近实际业务的组件,包含计数器和 TodoList 两个功能模块。它演示了如何读取状态、如何派发同步 action,以及如何在组件中处理表单输入。
// src/components/TodoCounter.jsx
import React, { useState } from "react";
import { useSelector, useDispatch } from "react-redux";
import { increment, decrement } from "../store/counterSlice";
import { addTodo, toggleTodo, removeTodo } from "../store/todoSlice";
function TodoCounter() {
// 1. 读取计数器状态
const count = useSelector((state) => state.counter.value);
// 2. 读取待办列表状态
const todos = useSelector((state) => state.todos.items);
// 3. 获取 dispatch
const dispatch = useDispatch();
// 4. 组件本地状态:输入框内容
const [inputValue, setInputValue] = useState("");
const handleAdd = () => {
if (inputValue.trim() === "") return;
// 5. 派发 addTodo action,payload 是新的待办内容
dispatch(addTodo(inputValue));
setInputValue("");
};
return (
<div style={{ padding: 24, maxWidth: 500, margin: "0 auto" }}>
{/* 计数器区域 */}
<section style={{ marginBottom: 32 }}>
<h2>计数器:{count}</h2>
<button onClick={() => dispatch(decrement())}>减 1</button>
<button onClick={() => dispatch(increment())}>加 1</button>
</section>
{/* 待办列表区域 */}
<section>
<h2>待办清单</h2>
<input
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
placeholder="请输入待办事项"
/>
<button onClick={handleAdd}>添加</button>
<ul>
{todos.map((todo) => (
<li key={todo.id}>
<span
onClick={() => dispatch(toggleTodo(todo.id))}
style={{
textDecoration: todo.completed ? "line-through" : "none",
cursor: "pointer",
}}
>
{todo.text}
</span>
<button onClick={() => dispatch(removeTodo(todo.id))}>
删除
</button>
</li>
))}
</ul>
</section>
</div>
);
}
export default TodoCounter;
配套 TodoList 的 Slice 代码:
// src/store/todoSlice.js
import { createSlice } from "@reduxjs/toolkit";
// 1. 初始状态
const initialState = {
items: [],
};
let nextId = 1;
const todoSlice = createSlice({
name: "todos",
initialState,
reducers: {
// 2. 添加待办:payload 为待办文本
addTodo: (state, action) => {
state.items.push({
id: nextId++,
text: action.payload,
completed: false,
});
},
// 3. 切换完成状态:payload 为待办 id
toggleTodo: (state, action) => {
const todo = state.items.find((item) => item.id === action.payload);
if (todo) {
todo.completed = !todo.completed;
}
},
// 4. 删除待办:payload 为待办 id
removeTodo: (state, action) => {
state.items = state.items.filter((item) => item.id !== action.payload);
},
},
});
export const { addTodo, toggleTodo, removeTodo } = todoSlice.actions;
export default todoSlice.reducer;
最后记得把 todoReducer 注册到 Store 中:
// src/store/index.js
import { configureStore } from "@reduxjs/toolkit";
import counterReducer from "./counterSlice";
import todoReducer from "./todoSlice";
const store = configureStore({
reducer: {
counter: counterReducer,
todos: todoReducer,
},
});
export default store;
2.7 异步请求实战:createAsyncThunk 与加载状态
实际业务中,状态更新往往不是瞬间完成的,比如从服务器获取数据。RTK 提供了 createAsyncThunk 来统一管理这种异步流程。
2.7.1 创建异步 Slice
// src/store/userSlice.js
import { createSlice, createAsyncThunk } from "@reduxjs/toolkit";
// 1. 创建异步 thunk:第一个参数是 action 的 type 前缀,第二个参数是异步函数
export const fetchUserById = createAsyncThunk(
"user/fetchUserById", // action type 前缀
async (userId, { rejectWithValue }) => {
// 参数:userId,thunk API 对象
try {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) {
throw new Error("请求失败");
}
return await response.json(); // 返回值会自动成为 fulfilled 的 payload
} catch (error) {
// 2. rejectWithValue 把错误信息传给 rejected 状态
return rejectWithValue(error.message);
}
},
);
// 3. 初始状态,包含 loading 和 error 字段
const initialState = {
userInfo: null, // 用户信息
loading: false, // 加载状态
error: null, // 错误信息
};
const userSlice = createSlice({
name: "user",
initialState,
reducers: {}, // 这里可以写同步 reducer
extraReducers: (builder) => {
// 4. 处理异步请求的三个阶段:pending、fulfilled、rejected
builder
.addCase(fetchUserById.pending, (state) => {
state.loading = true; // 开始请求,显示加载中
state.error = null;
})
.addCase(fetchUserById.fulfilled, (state, action) => {
state.loading = false; // 请求成功,隐藏加载
state.userInfo = action.payload; // 保存返回的数据
})
.addCase(fetchUserById.rejected, (state, action) => {
state.loading = false; // 请求失败,隐藏加载
state.error = action.payload; // 保存错误信息
});
},
});
export default userSlice.reducer;
2.7.2 在组件中使用异步 thunk
// src/components/UserProfile.jsx
import React, { useEffect } from "react";
import { useSelector, useDispatch } from "react-redux";
import { fetchUserById } from "../store/userSlice";
function UserProfile({ userId }) {
// 1. 读取 user 模块的状态
const { userInfo, loading, error } = useSelector((state) => state.user);
const dispatch = useDispatch();
// 2. 组件挂载时发起异步请求
useEffect(() => {
dispatch(fetchUserById(userId));
}, [dispatch, userId]);
// 3. 根据 loading 和 error 渲染不同 UI
if (loading) return <p>加载中,请稍候……</p>;
if (error) return <p style={{ color: "red" }}>出错了:{error}</p>;
if (!userInfo) return null;
return (
<div style={{ padding: 24 }}>
<h2>用户资料</h2>
<p>姓名:{userInfo.name}</p>
<p>邮箱:{userInfo.email}</p>
</div>
);
}
export default UserProfile;
2.7.3 异步流程图(文字版)
组件挂载
│
▼
dispatch(fetchUserById(userId))
│
├──────────▶ pending 状态 ───▶ loading = true,显示 loading
│
├──────────▶ fulfilled 状态 ───▶ loading = false,保存 userInfo
│
└──────────▶ rejected 状态 ───▶ loading = false,保存 error
2.7.4 与 React 19 并发特性的结合
React 19(课程环境基于 React 18)的并发渲染特性允许 React 在渲染过程中暂停、恢复和中断更新。对于 Redux 来说:
useSelector默认会在每次 Store 更新时检查组件是否需要重新渲染。- 在并发模式下,组件渲染可能被中断。Redux 的状态读取本身不受影响,因为 Store 的更新是同步的。
- 在
useEffect中发起异步请求时,不需要额外处理并发逻辑,但要保证在组件卸载时不会继续操作已卸载组件的状态(例如避免 setState on unmounted component)。
一句话:使用 RTK 的
createAsyncThunk+ 组件内useEffect派发,就已经能很好地配合 React 19 的并发特性。重点是不要把异步副作用写在 reducer 里。
2.8 模块二小结
本模块我们学习了:
- 安装
@reduxjs/toolkit和react-redux依赖。 - 使用
createSlice创建包含状态、reducer 和 action 的 Slice。 - 使用
configureStore创建 Store,并在Provider中注入。 - 在组件中使用
useSelector读取状态、useDispatch派发 action。 - 使用
createAsyncThunk+extraReducers处理异步请求,管理 loading 和 error。
课后思考
- 如果 Store 里没有注册
counterreducer,组件中state.counter.value会读到什么? useSelector返回的数据发生变化时,组件会做什么?- 为什么
createAsyncThunk的异步函数要写在 Slice 外面,而不是写在reducers里?
模块三:开发注意事项——React 19 中的踩坑与误区
学习目标
- 能识别并避免状态解构、组件外 dispatch、重渲染等常见错误。
- 理解 React 19 并发渲染与 Redux 配合时的注意事项。
- 学会判断“什么状态应该放 Redux,什么状态应该放组件本地”。
3.1 踩坑点一:状态解构导致订阅失效
错误写法
// ❌ 错误:在 useSelector 里解构整个 counter 对象
const { value } = useSelector((state) => state.counter);
// 看起来没问题,但如果 counter 里新增了其他字段,
// 或每次返回新的对象引用,会导致不必要的重渲染。
问题原因
useSelector 使用 === 严格比较来判定状态是否变化。如果 selector 函数每次都返回一个新的对象字面量,即使内容没变,引用不同也会触发重渲染。
// ❌ 错误:每次返回新的对象,引用不同,始终触发重渲染
const user = useSelector((state) => ({ ...state.user }));
正确写法
// ✅ 正确:只返回需要的原始值,确保引用稳定
const count = useSelector((state) => state.counter.value);
const userName = useSelector((state) => state.user.name);
如果需要返回多个字段,建议使用多个 useSelector 分别读取,或者使用 shallowEqual 进行浅比较:
// ✅ 使用 shallowEqual 比较对象,避免误触发重渲染
import { useSelector, shallowEqual } from "react-redux";
const { name, age } = useSelector(
(state) => ({ name: state.user.name, age: state.user.age }),
shallowEqual,
);
3.2 踩坑点二:在组件外调用 dispatch
错误写法
// ❌ 错误:在普通 JS 文件中直接调用 dispatch
import store from "./store";
import { increment } from "./counterSlice";
store.dispatch(increment()); // 这里能运行,但通常不推荐
问题原因
虽然直接导入 store 可以调用 dispatch,但这会导致组件与 Store 强耦合,难以测试和复用。而且普通 JS 文件不属于 React 组件树,无法享受 React-Redux 的上下文能力。
正确写法
在组件内使用 useDispatch:
// ✅ 正确:在组件内通过 useDispatch 获取 dispatch
function Counter() {
const dispatch = useDispatch();
const handleClick = () => {
dispatch(increment());
};
return <button onClick={handleClick}>+1</button>;
}
如果确实需要在组件外(例如路由守卫、拦截器)修改状态,可以通过注入 store 的方式处理,但这类场景应该严格控制,避免滥用。
3.3 踩坑点三:不必要的重渲染
问题现象
父组件中使用 useSelector 读取了一个大对象,即使子组件只依赖其中部分字段,也会跟着重新渲染。
错误写法
// ❌ 错误:把整个 user 对象传给子组件
function Parent() {
const user = useSelector((state) => state.user);
return <Child user={user} />;
}
function Child({ user }) {
return <p>{user.name}</p>;
}
正确写法
// ✅ 正确:只把子组件需要的字段传下去
function Parent() {
const userName = useSelector((state) => state.user.name);
return <Child name={userName} />;
}
function Child({ name }) {
return <p>{name}</p>;
}
// 或者让子组件自己使用 useSelector 读取所需状态
function Child() {
const name = useSelector((state) => state.user.name);
return <p>{name}</p>;
}
优化技巧总结
- Selector 粒度尽量细:只读取组件真正需要的最小状态片段。
- 避免返回新对象:不要在
useSelector里做return { ...state.user }这类操作。 - 使用
React.memo包裹纯展示组件:如果父组件的 props 或状态没变,子组件可以跳过渲染。 - 必要时使用
shallowEqual:多个字段需要一起返回时,用浅比较避免误触发。
3.4 踩坑点四:并发渲染与状态更新冲突
React 19(课程环境基于 React 18)的并发特性允许 React 中断、恢复和延迟渲染。对于 Redux 的使用者来说,需要注意以下几点:
3.4.1 不要在 render 过程中直接修改 Store
// ❌ 错误:在渲染阶段直接调用 dispatch
function BadComponent() {
const dispatch = useDispatch();
dispatch(someAction()); // 渲染阶段不允许有副作用!
return <div>hello</div>;
}
正确写法
// ✅ 正确:副作用放在 useEffect 或事件处理函数中
function GoodComponent() {
const dispatch = useDispatch();
useEffect(() => {
dispatch(someAction());
}, [dispatch]);
return <div>hello</div>;
}
3.4.2 避免在渲染阶段读取外部可变状态
// ❌ 错误:直接读取 store.getState(),可能读到不一致的状态
const state = store.getState();
function MyComponent() {
return <div>{state.counter.value}</div>;
}
正确写法
// ✅ 正确:使用 useSelector,让 React-Redux 帮你管理订阅和更新
function MyComponent() {
const count = useSelector((state) => state.counter.value);
return <div>{count}</div>;
}
3.4.3 异步副作用交给 createAsyncThunk
在并发渲染下,组件可能被卸载、中断。如果异步逻辑写在组件里,需要手动处理 cleanup。使用 createAsyncThunk 可以把异步逻辑统一管理,减少出错概率。
3.5 初学者误区一:过度用 Redux 管理本地状态
错误示范
// ❌ 错误:把输入框这种纯本地状态也放到 Redux
const inputValue = useSelector((state) => state.form.inputValue);
const dispatch = useDispatch();
<input
value={inputValue}
onChange={(e) => dispatch(setInputValue(e.target.value))}
/>;
问题原因
输入框的值只在当前组件内部使用,不需要共享给其他组件。把它放到 Redux 里会让 Store 变得臃肿,每次输入都会触发全局状态更新和 selector 检查,得不偿失。
正确写法
// ✅ 正确:本地状态用 useState 管理
const [inputValue, setInputValue] = useState("");
<input value={inputValue} onChange={(e) => setInputValue(e.target.value)} />;
3.6 初学者误区二:Store 状态冗余
错误示范
// ❌ 错误:在 Store 里保存可以由其他状态计算得出的值
const initialState = {
todos: [],
completedCount: 0, // 这个字段可以从 todos 中计算出来
};
问题原因
completedCount 和 todos 本质上是同一数据的不同表现形式。如果每次添加、删除、切换待办时都要同步更新 completedCount,很容易出现不一致。
正确写法
// ✅ 正确:只保存原始数据,计算值在组件或 selector 中计算
const completedCount = useSelector(
(state) => state.todos.items.filter((todo) => todo.completed).length,
);
或者使用 RTK 的 createSelector 做缓存优化:
import { createSelector } from "@reduxjs/toolkit";
const selectTodos = (state) => state.todos.items;
export const selectCompletedCount = createSelector(
[selectTodos],
(todos) => todos.filter((todo) => todo.completed).length,
);
3.7 状态选型判断标准
遇到一个新的状态,不知道放 Redux 还是放组件 useState?可以参考下面这张决策表:
| 判断维度 | 建议放 Redux | 建议放 useState |
|---|---|---|
| 是否需要跨组件共享 | 是 | 否 |
| 是否需要持久化或服务端同步 | 是 | 否 |
| 是否需要在多个页面之间保持一致 | 是 | 否 |
| 是否只在当前组件内部使用 | 否 | 是 |
| 是否只是 UI 临时状态(如输入框、弹窗显隐) | 否 | 是 |
一句话总结:共享、持久、跨页面的状态放 Redux;局部、临时、UI 状态放 useState。
3.8 模块三小结
本模块我们学习了:
- 状态解构、组件外 dispatch、重渲染是 React 19 + Redux 开发中的三大常见踩坑点。
- 并发渲染下,dispatch 和状态读取要遵循 React 的渲染规则,避免副作用出现在 render 阶段。
- 不要过度使用 Redux,本地状态和可计算值不要放到 Store 里。
课后思考
- 你在自己的项目中有没有把本该用 useState 的状态放进了 Redux?
- 如果你的组件只读取
state.user.name,那么state.user.age变化时,该组件会重新渲染吗? - 在并发渲染下,为什么
useEffect比直接在 render 里 dispatch 更安全?
附录 A:Redux 状态流转总流程图(文字版)
┌────────────────────────────────────────────────────────────────────┐
│ Redux 全局状态流转图 │
└────────────────────────────────────────────────────────────────────┘
┌──────────────┐
│ 初始状态 │
│ initialState │
└──────┬───────┘
│
▼
┌──────────────────────────────────────────────────────────┐
│ Store │
│ 唯一的数据仓库(单一数据源) │
└──────────────────────────────────────────────────────────┘
│
│ 保存全局状态
▼
┌──────────┐ action ┌──────────┐ 新状态 ┌──────────┐
│ 用户交互 │──────────────▶│ reducer │─────────────▶│ Store │
│ 或异步回调 │ │ 纯函数 │ │ 更新 │
└──────────┘ └──────────┘ └──────────┘
│
│ 状态变化通知订阅者
▼
┌──────────────────────────────────────────────────────────┐
│ React 组件通过 useSelector 订阅状态 │
│ 组件重新渲染 │
└──────────────────────────────────────────────────────────┘
关键规则:
1. 所有状态集中在 Store 中。
2. 改变状态必须通过 dispatch 发送 action。
3. reducer 必须是纯函数,返回新状态而不是修改原状态。
4. 组件通过 useSelector 读取,通过 useDispatch 更新。
附录 B:常见问题解答(FAQ)
Q1:Redux 和 Context 有什么区别?能不能互相替代?
答:Context 适合传递“很少变化”的数据,比如主题、语言、用户信息等。Redux 适合“频繁变化、需要统一管理、有复杂更新逻辑”的状态。两者可以共存,不要二极管思维。例如全局主题可以用 Context,购物车、用户登录状态可以用 Redux。
Q2:React 19 了,还需要 Redux 吗?useReducer 不够吗?
答:useReducer 是 React 内置的 reducer 模式,适合管理单个组件或局部共享的状态。当状态需要跨多个页面、多个组件共享,或者有复杂的异步流程时,Redux(特别是 RTK)仍然是更好的选择。
Q3:为什么我的 useSelector 没有重新渲染?
答:最常见的原因是 selector 返回了同一个引用,或者 selector 没有正确读取到变化的字段。检查:
- 是否读取了正确的路径?
- 是否返回了新的对象字面量导致引用不变但实际没变?
- 是否使用了
shallowEqual?
Q4:createAsyncThunk rejected 时,错误信息为什么是 undefined?
答:如果你在异步函数里用 throw new Error(...) 但没有使用 rejectWithValue,RTK 会把错误对象放进 action.error,而不是 action.payload。建议始终用 rejectWithValue(error.message) 返回结构化错误信息。
Q5:我可以在 reducer 里发起异步请求吗?
答:绝对不行。reducer 必须是纯函数,不能有副作用。异步请求请使用 createAsyncThunk 或放在组件的 useEffect 中。
Q6:Redux DevTools 怎么开启?
答:使用 RTK 的 configureStore 时,Redux DevTools 扩展会自动开启。你只需在浏览器中安装 Redux DevTools 插件,打开控制台即可查看每个 action 和状态变化。
Q7:所有状态都放 Redux 会不会让项目更简单?
答:不会。过度集中会让 Store 变得臃肿、组件渲染不必要地频繁。建议遵循“需要共享的放 Redux,其余的放本地 state”的原则。
Q8:React 19 的并发渲染会让 Redux 的更新丢失吗?
答:不会。Redux 的 Store 更新是同步的,React 的并发渲染只是让 React 组件渲染可以中断和恢复。只要你在组件内正确使用 useSelector 和 useDispatch,就不会丢失更新。
总结
全文总结
通过本节课,你应该建立起以下认知:
- Redux 解决的是 React 中跨组件共享状态、避免 props 层层传递的问题。
- Redux 的三大原则——单一数据源、状态只读、纯函数修改——决定了它的可预测性和可调试性。
- Redux Toolkit(RTK)是官方推荐写法,能显著减少样板代码和出错概率。
- 在 React 19 项目中,使用
createSlice、configureStore、Provider、useSelector和useDispatch就能完成大部分状态管理需求。 - 异步请求用
createAsyncThunk统一管理,配合 loading 和 error 状态做 UI 反馈。 - 开发时要注意:避免状态解构订阅失效、不要在组件外随意 dispatch、避免不必要的重渲染、不要把本地状态和可计算值塞进 Redux。