Redux 状态管理速查表 - Redux Toolkit 与 React 状态管理大全
这张速查给想在 React 里做可预测、集中式状态管理的开发者,用的是 Redux Toolkit 这套现代化写法。内容涵盖 store 配置、createSlice(自动生成 action、靠 Immer 保证不可变)、createAsyncThunk 及其 pending/fulfilled/rejected 三态处理、中间件,以及 React 侧的 useSelector/useDispatch 绑定。收尾章节用 createEntityAdapter 展示规范化实体管理,并顺带看一眼 RTK Query。读完你应能搭好一个 slice、同时派发同步与异步 action、响应式读取状态,并把集合建模成按 id 存储的规范化实体。
典型使用场景
面向正在把散落的 useState / Context 状态收进单一 store 的前端团队,尤其是从手写 redux 迁到 Redux Toolkit(RTK)的项目。RTK 真正的价值不在少写 action 常量,而在三处约定:reducer 必须是纯函数(Immer 帮你做不可变更新)、slice 的 name 自动成为 action 前缀、以及 createAsyncThunk 生成的 pending / fulfilled / rejected 三个 action 天然对应请求三态。本表覆盖 store 配置、slice、thunk、中间件、选择器与实体适配器,以及为什么 useSelector 必须返回稳定引用这条最容易踩的性能坑。
Store 配置 5
const store = configureStore({ reducer: rootReducer })configureStore({ reducer: { users: usersReducer, posts: postsReducer } })configureStore({ middleware: (gdm) => gdm().concat(logger) })configureStore({ devTools: process.env.NODE_ENV !== "production" })const rootReducer = combineReducers({ users, posts })Slice 创建 6
const slice = createSlice({ name, initialState, reducers })name: "counter"initialState: { count: 0 }reducers: { increment(state) { state.count += 1 } }extraReducers: (builder) => builder.addCase(fulfilled, ...)export const { increment } = slice.actionsActions 与 Dispatch 5
dispatch(increment())dispatch(fetchUser(id))const { increment } = counterSlice.actionsstore.dispatch({ type: "counter/increment" })export default slice.reducer异步处理 5
const fetchUser = createAsyncThunk("user/fetch", async (id) => {})builder.addCase(fetchUser.pending, (state) => {})builder.addCase(fetchUser.fulfilled, (state, action) => {})builder.addCase(fetchUser.rejected, (state, action) => {})await dispatch(fetchUser(id)).unwrap()中间件 5
const logger = () => (next) => (action) => {}middleware: (gdm) => gdm().concat(customMiddleware)configureStore({ middleware: (gdm) => gdm({ serializableCheck: false }) })import { thunk } from "redux-thunk"gdm({ immutableCheck: false, serializableCheck: false })选择器 5
useSelector((state) => state.counter.value)const dispatch = useDispatch()const selectCount = (state) => state.counter.valueconst selectUserById = createSelector(...)useSelector(selectUserById, shallowEqual)常用模式 6
<Provider store={store}><App /></Provider>state.entities[id]createEntityAdapter({ selectId: (item) => item.id })adapter.getSelectors((state) => state.items)adapter.upsertOne(state, item)import { createApi } from "@reduxjs/toolkit/query/react"参数矩阵
| 参数 | 作用 | 示例 |
|---|---|---|
configureStore({ reducer }) | 创建 store,自动内置 thunk 与 devTools 等默认中间件 | const store = configureStore({ reducer: rootReducer }) |
configureStore({ reducer: { a, b } }) | 传对象时多个 reducer 自动合并成根状态 | configureStore({ reducer: { users: usersReducer, posts: postsReducer } }) |
createSlice({ name, initialState, reducers }) | 一次定义 name / initialState / reducers,自动生成同名 action 与 reducer | const slice = createSlice({ name, initialState, reducers }) |
extraReducers: (builder) => ... | 在 slice 内响应其它 slice 或外部 thunk 的 action | extraReducers: (builder) => builder.addCase(fulfilled, ...) |
createAsyncThunk(typePrefix, payloadCreator) | 创建异步 thunk,自动产生三个生命周期 action | const fetchUser = createAsyncThunk("user/fetch", async (id) => {}) |
dispatch(thunk).unwrap() | 在组件里 await thunk,失败时直接抛出错误而非返回 rejected action | await dispatch(fetchUser(id)).unwrap() |
middleware: (gdm) => gdm().concat(custom) | 保留默认中间件并追加自定义中间件 | configureStore({ middleware: (gdm) => gdm().concat(logger) }) |
useSelector(selectorFn, shallowEqual) | 订阅式读取状态;用浅比较避免每次返回新对象导致的重渲染 | useSelector(selectUserById, shallowEqual) |
createSelector(...) | 创建记忆化选择器,依赖不变时复用上次结果 | const selectUserById = createSelector(...) |
adapter.getSelectors(state => state.items) | 取得实体适配器的选择器集合,配合规范化状态使用 | adapter.getSelectors((state) => state.items) |
易错点与避坑指南
现象reducer 里写了 fetch / setTimeout / localStorage 写入,控制台警告「Unexpected side effect in reducer」,且状态时序错乱。
原因reducer 必须是纯函数,同样的输入必须得到同样的输出,副作用让 Redux DevTools 的时间旅行失效。
处置把这些副作用挪进 thunk 或组件层:const fetchUser = createAsyncThunk("user/fetch", async (id) => {}),reducer 只负责改 state。
现象在 Immer 管理的 state 上做了原地修改之外的操作,返回的还是同一个引用,组件不更新。
原因reducer 直接 return 了原 state 对象或某个未修改的子对象,引用没变,useSelector 认为状态未变化。
处置所有修改都要在 Immer draft 上写并 return state;跨 reducer 复用同一个对象引用时也要展开:state.count += 1 这类写法由 Immer 生成新引用。
现象useSelector(() => ({ a: state.a, b: state.b })) 导致每次 store 更新都重渲染。
原因选择器每次返回一个新对象引用,默认引用比较永远判定为「变了」。
处置改用浅比较:useSelector(selectUserById, shallowEqual),或把选择器抽出去用 createSelector 记忆化。
现象请求失败时页面没有任何错误提示,await dispatch(...) 也没抛错。
原因dispatch 一个 rejected 的 thunk 默认会 resolve 成一个 rejected action 对象,而不是抛出。
处置在调用处加 .unwrap():await dispatch(fetchUser(id)).unwrap(),用 try/catch 捕获;或读 action.payload 作为错误原因。
现象自定义中间件写完后完全没被调用,或报错说不该同时有 thunk。
原因middleware 被写成默认中间件之外的形式(漏了 gdm()),把 thunk 等默认中间件整体覆盖掉了。
处置写成 configureStore({ middleware: (gdm) => gdm().concat(customMiddleware) }),只追加不替换;仅在明确知道代价时用 gdm({ serializableCheck: false }) 调整选项。
现象开发环境控制台刷出 immutableCheck / serializableCheck 警告,页面却能正常工作。
原因state 里放了 Date、Map 或函数这类不可序列化或可变结构,检查器只是在提醒。
处置按提示把这类值移出 store(存时间戳字符串、id 列表),确有必要时再 gdm({ immutableCheck: false, serializableCheck: false })。
排障路径
1确认 store 装配与 slice 命名空间
configureStore({ reducer: { users: usersReducer, posts: postsReducer } })每个 slice 的 name 会成为 action 前缀(如 counter/increment),排查 action 时先确认这个前缀。
2追踪一个异步请求的完整生命周期
builder.addCase(fetchUser.pending, (state) => {})pending / fulfilled / rejected 三个 case 各加一行日志,即可确认请求到底有没有发出、走的哪个分支。
3定位组件为何频繁重渲染
useSelector(selectUserById, shallowEqual)把选择器改成记忆化并加浅比较;若仍频繁重渲染,说明被订阅的切片本身变化过密,应下沉到更细的 slice。
4验证中间件链没有被覆盖
configureStore({ middleware: (gdm) => gdm().concat(logger) })gdm() 先取出默认链再 concat 自己的中间件;漏掉 gdm() 会把 thunk 一并顶掉。
5在组件里拿到 dispatch 并直接派发原始 action
store.dispatch({ type: "counter/increment" })调试时可以直接派发带 type 的原始对象验证 reducer,但业务代码应使用 slice 自动生成的 action creator。
命令示例
装配 store 并合并多个 reducer
const store = configureStore({ reducer: rootReducer })
configureStore({ reducer: { users: usersReducer, posts: postsReducer } })
const rootReducer = combineReducers({ users, posts })传对象时多个 reducer 自动合并成根状态;configureStore 默认已带上 thunk 与 devTools 等中间件。
一个 slice 从定义到派发
const slice = createSlice({
name: "counter",
initialState: { count: 0 },
reducers: { increment(state) { state.count += 1 } }
})
export const { increment } = slice.actions
export default slice.reducer
dispatch(increment())name 自动成为 action 前缀(counter/increment),reducers 里的原地赋值由 Immer 生成不可变更新。
异步 thunk 的三个生命周期分支
const fetchUser = createAsyncThunk("user/fetch", async (id) => {})
builder.addCase(fetchUser.pending, (state) => {})
builder.addCase(fetchUser.fulfilled, (state, action) => {})
builder.addCase(fetchUser.rejected, (state, action) => {})
await dispatch(fetchUser(id)).unwrap()在组件里用 .unwrap() 才能让失败直接抛错;不加 .unwrap() 时 dispatch 返回的是 action 对象而不是结果。
在组件里读取状态并避免多余渲染
const dispatch = useDispatch()
useSelector((state) => state.counter.value)
useSelector(selectUserById, shallowEqual)
const selectUserById = createSelector(...)返回原始值最稳;必须返回对象时给 shallowEqual,或用 createSelector 做记忆化复用上次结果。
常见坑与注意事项
- reducer 里绝不能出现 fetch、setTimeout、localStorage 写入等副作用,控制台会报「Unexpected side effect in reducer」且状态时序会错乱。
- useSelector(() => ({ a: state.a, b: state.b })) 每次返回新引用会让组件反复重渲染,改用原始值选择器或加 shallowEqual。
- await dispatch(thunk) 不加 .unwrap() 时失败只返回 rejected action、不抛错,错误必须自己在 rejected 分支里读。
- middleware 必须写成 (gdm) => gdm().concat(...) ;直接传数组会覆盖默认链,而 RTK 已内置 redux-thunk,再加一份会报错。
- 绕过 draft 改动状态(比如在别处持同一对象引用原地修改)会让引用不变,组件不会重渲染;实体集合请用 adapter.upsertOne。
提示
- Redux 三大原则:单一数据源、状态只读、纯函数修改状态。
- RTK 内置 Immer,可在 reducer 中直接"修改"状态,底层自动生成不可变更新。
- useSelector 会在 store 更新时重新执行,返回新引用会触发重渲染,注意性能优化。
- Redux Toolkit (RTK) 是官方推荐的方式,configureStore 和 createSlice 大幅简化样板代码。
- createAsyncThunk 配合 extraReducers 处理异步,redux-thunk 已内置在 RTK 默认中间件中。
常见问题
createSlice 到底省掉了哪些样板代码?
省掉三块:手写的 action type 常量字符串、与 type 一一对应的 action creator 函数,以及 switch 风格的 reducer。用 createSlice({ name, initialState, reducers }) 写一次,name 会自动成为生成 action 的前缀,reducers 里的每个键同时产出一个同名 action creator(counter/increment、counter/increment),导出 const { increment } = slice.actions 即可派发。它不替你决定 state 的形状,也不减少 reducer 必须保持纯函数这条要求。
为什么在 Immer 管理的 state 上改了却没触发重渲染?
前提是这个改动确实是通过 Immer 的 draft 进行的。只要你没有走 createSlice 里那个受管的 state,而是把同一份对象在 reducer 之外又拿了一份引用去原地改,返回给 store 的还是旧引用,引用相等就不会通知订阅者。排查方法是把这个 action 单独派发一次看状态是否变化,若不变就说明修改发生在 draft 之外。createEntityAdapter({ selectId: (item) => item.id }) 管理的集合则要用 adapter.upsertOne(state, item) 这类方法更新。
createAsyncThunk 的三个 action 该在哪个组件处理?
pending / fulfilled / rejected 三个生命周期 action 都写进 slice 的 extraReducers,靠 builder.addCase 统一落到同一份状态上,这样「请求中」标志、结果、错误信息三处都不必散落在各个组件的本地 state 里。组件侧只负责派发:dispatch(fetchUser(id)),需要拿到返回值或让失败直接抛出时用 await dispatch(fetchUser(id)).unwrap()。把 fulfilled 的赋值逻辑留在组件里,就等于绕过了 extraReducers 的约定。
useSelector 为什么会引起不必要的重渲染?
因为 store 每次更新都会重新执行选择器函数,而你若在函数体里返回一个新对象(() => ({ a: state.a, b: state.b })),每次都是新引用,浅比较也判为不相等。两个办法:优先写成 useSelector((state) => state.counter.value) 这种返回原始值的形态;必须返回对象时给第二个参数传 shallowEqual,或者用 createSelector(...) 做记忆化,依赖不变时复用上次结果。需要跨 id 取数据时,adapter.getSelectors((state) => state.items) 提供的选择器已经做了这层处理。
自定义中间件怎么确认真的挂上了?
写 middleware: (gdm) => gdm().concat(customMiddleware),关键是先调用 gdm() 拿到默认中间件再 concat,直接传一个数组会覆盖默认链。RTK 默认已内置 redux-thunk(import { thunk } from "redux-thunk" 演示的就是这个中间件),所以自己再塞一个会报错。中间件签名是三层柯里化:const logger = () => (next) => (action) => {},最外层在 store 初始化时调用一次。确认方式是给它加一句控制台输出,派发一个同步 action 看有没有打出来。
由 巧匠 维护
公开更新于 2026年7月21日,内容持续校对官方文档。