react-redux 从入坑到进阶:Store、Action、Reducer 与异步状态管理

📅 发布时间:2026/10/11 9:05:41
react-redux 从入坑到进阶:Store、Action、Reducer 与异步状态管理
如果你在一个 React 团队里待过一阵子大概率听到过这样的对话这个页面就一个按钮要共享状态没必要上 Redux 吧 然后过了一周同一个组件的 props 已经穿了四五层父组件为了给孙辈传一个回调中间的每一层都写满了转发函数。react-redux 到底该怎么用很多人的理解其实只停留在知道有这个东西真到自己搭项目的时候要么过度设计要么用得一塌糊涂。这篇东西不是官方文档的复读是我在多个项目里实际使用 react-redux 的经验整理。适合刚接触 Redux、被 connect 和 useSelector 两种写法绕晕的新人也适合那种已经在用、但经常遇到为什么我的组件不更新为什么改了 A 组件结果 B 组件也重渲染这类问题的同学。我会先讲它解决了什么问题再把 store、action、reducer 三个核心概念理清楚然后分别演示老牌的 connect 写法和现在推荐的 hooks 写法最后聊聊异步处理和几个让我印象深刻的坑。1. 先搞清楚react-redux 到底帮你解决了什么问题先说一个反直觉的事情react-redux 并不是 Redux 本身。Redux 是一个和框架无关的状态管理库它在纯 JavaScript 环境里也能跑。react-redux 是 Redux 官方提供的 React 绑定库它的作用是让 React 组件能读到 Redux store 里的状态并且在状态变化时自动触发组件更新。如果你只用原生 Redux想让组件响应状态变化必须手动调用 store.subscribe然后在回调里通过 setState 触发重新渲染。这套流程写一两次还行写多了全是重复样板代码。react-redux 把这些都封装好了它提供 Provider 把 store 注入整个组件树提供 connect 或者 useSelector、useDispatch 这类 API 让你在组件里声明我要读哪些状态我要派发哪些动作剩下的事情交给库内部处理。1.1 组件通信的痛点React 本身的组件通信方式相信大家都熟父传子用 props子传父用回调函数。问题出在状态需要被多个互不相干的组件共享时。比如一个电商应用购物车图标上的角标数量商品列表页要更新它详情页要更新它结算页也要更新它这些页面还不在同一个组件层级里。如果全靠 props 传递你得把状态提升到某个公共祖先组件然后再层层往下传中途任何一层改动都会牵一发动全身。Redux 的解决思路是把这种共享状态抽出来集中放到一个全局的 store 里。任何组件都可以选中性地订阅自己关心的部分任何组件都可以通过 dispatch 一个 action 来请求状态变更。组件之间不再需要直接通信都跟 store 对话就好。1.2 什么时候不该用 Redux这里得说句公道话Redux 不是银弹。如果只是父子组件通信或者一个表单的内部状态用 useState 加 props 完全够硬上 Redux 只会增加样板代码。我见过一个项目每个组件都往 store 里塞状态结果 store 里几百个字段组件之间互相影响排查起来比 props 层层传递还痛苦。我自己判断是否引入 Redux 的标准是三个问题。第一这个状态是否被多个互不相关的组件共享第二这个状态是否需要跨页面跨路由保持第三这个状态的变更过程是否需要被追踪和调试如果三个都答否大概率不需要 Redux如果有两个答是就可以考虑。分享一个实用的替代方案跨页面共享用 Context 也能应对一些场景但 Context 有一个性能特点Provider 的 value 一旦变化所有消费该 Context 的组件都会重新渲染没有 Redux 那样精细的订阅粒度。状态一复杂Redux 反而更清爽。2. Store、Action、Reducer一次性把三个核心概念理清楚在动手写代码之前必须把 Redux 的三个核心概念搞清楚。很多教程一上来就贴代码概念没讲明白读者照抄能跑一改就懵。Store 是全局唯一的数据容器里面保存着整个应用的状态树。一个应用只能有一个 store。Action 是一个普通对象必须有一个 type 字段type 是一条字符串用来描述发生了什么。Reducer 是一个纯函数接收当前的 state 和 action返回新的 state。2.1 三个概念各管什么用一个生活化的类比来理解store 是银行的金库state 是金库里的账本内容。action 是客户填写的取款单上面写清楚我要取款 100 元type 是取款操作payload 是金额。reducer 是银行柜员接到取款单后核对账本、修改账本、把新账本放回金库。柜员不能私自从口袋里掏钱改账每次操作都必须基于当前账本和取款单重新算出一版新账本这就是 reducer 必须是纯函数的原因。Redux 的数据流是单向的。组件通过 dispatch(action) 发出指令store 把当前 state 和 action 交给 reducerreducer 返回新的 statestore 更新自己的内容所有订阅了相关状态的组件拿到新值并重新渲染。这个方向永远不会反过来状态不会从组件内部直接流回 store。2.2 为什么 reducer 必须是纯函数Redux 对 reducer 的要求总结下来有三点一是必须返回新的 state 对象不能直接修改原来的 state二是不能在 reducer 里做异步操作、生成随机数、调用 Date 等副作用三是遇到未匹配的 action.type 时必须返回当前 state 的默认值。为什么要这么严格因为 Redux 依赖纯函数才能实现时间旅行调试、状态预测和单元测试。如果 reducer 里有随机数或者异步逻辑同一个 action 在不同时间执行就会产生不同结果调试时整个状态流就不可预测了。这也是它被很多人吐槽严格的原因但这份严格换来的是高度的可追踪性。一个最基础的 reducer 长这样// counterReducer.js const initialState { count: 0 }; function counterReducer(state initialState, action) { switch (action.type) { case INCREMENT: return { ...state, count: state.count 1 }; case DECREMENT: return { ...state, count: state.count - 1 }; case INCREMENT_BY: return { ...state, count: state.count action.payload }; default: return state; } } export default counterReducer;注意这里我用的是展开运算符创建新对象而不是直接 state.count 1。这是 Redux 里最重要的编码习惯后面我会单独讲这个坑会带来什么样的诡异表现。2.3 创建 store 与合并多个 reducerStore 的创建通常放在入口文件或者是独立封装一个 store 配置文件。最简单的方式如下import { createStore } from redux; import counterReducer from ./counterReducer; const store createStore(counterReducer);当业务拆成多个模块后一个 reducer 文件会变得很长所以一般用 combineReducers 把不同领域的 reducer 合并成根 reducerimport { createStore, combineReducers } from redux; import counterReducer from ./counterReducer; import userReducer from ./userReducer; const rootReducer combineReducers({ counter: counterReducer, user: userReducer, }); const store createStore(rootReducer);combineReducers 的价值在于分治。每个模块的 reducer 只关心自己领域内的状态最后合并成一个完整的对象。合并完成之后访问不同模块的状态就是 state.counter.count、state.user.name 这样的路径结构非常清晰。3. Provider 与 connect经典写法里的数据注入链路接下来是 react-redux 开始发挥作用的环节。老项目里最常见的是 connect 的高阶组件写法官方文档在很长一段时间里都推荐这种模式所以存量代码里非常普遍。你进任何一家用了 Redux 的公司基本都会遇到。3.1 Provider 把 store 注入整个组件树为了让组件树里的任何组件都能访问到 storereact-redux 提供了一个 Provider 组件。Provider 接收一个 store 属性作为整个应用的顶层容器。在 React 18 的入口文件里常见写法是import React from react; import { createRoot } from react-dom/client; import { Provider } from react-redux; import { createStore, combineReducers } from redux; import App from ./App; import counterReducer from ./counterReducer; import userReducer from ./userReducer; const rootReducer combineReducers({ counter: counterReducer, user: userReducer, }); const store createStore(rootReducer); const root createRoot(document.getElementById(root)); root.render( Provider store{store} App / /Provider );Provider 内部本质上是借助 React 的 Context 机制把 store 放到上下文中。后面要讲的 connect、useSelector、useDispatch之所以能拿到 store都是从这个 Context 里取值。这里记住一个要点Provider 必须包裹在组件树最外层如果位置不对或者 store 没传进去下面的组件就会报错最常见的错误信息就是 Could not find react-redux context value。3.2 connect 的两个配置函数组件端的使用是重头戏。connect 是一个高阶函数第一次调用传配置参数第二次调用传组件本身。最常用的配置参数有两个mapStateToProps 和 mapDispatchToProps。mapStateToProps 负责把 store 里的状态映射成组件的 props。它接收整个 state 作为参数返回一个对象对象里的每个字段都会成为目标组件的 props。mapDispatchToProps 负责把 dispatch 映射成组件的 props组件调用这些映射出来的函数时就等于在派发 action。一个完整的 Counter 例子import { connect } from react-redux; function Counter({ count, increment, decrement }) { return ( div p当前值{count}/p button onClick{increment}1/button button onClick{decrement}-1/button /div ); } const mapStateToProps (state) ({ count: state.counter.count, }); const mapDispatchToProps (dispatch) ({ increment: () dispatch({ type: INCREMENT }), decrement: () dispatch({ type: DECREMENT }), }); export default connect(mapStateToProps, mapDispatchToProps)(Counter);这个例子里Counter 组件本身并不知道 Redux 的存在它接收 props 然后展示。connect 返回一个新组件新组件负责订阅 store、计算需要的状态、绑定 dispatch然后把结果传给原组件。这种先写纯展示组件再用容器组件包裹的思路就是早期 Redux 社区常说的容器组件与展示组件分离。mapDispatchToProps 还有一种偷懒的对象简写方式const increment () ({ type: INCREMENT }); const decrement () ({ type: DECREMENT }); export default connect(mapStateToProps, { increment, decrement })(Counter);connect 检测到第二个参数是普通对象时会自动把对象里的每个 action creator 用 dispatch 包裹起来效果和上面的函数写法一模一样。团队协作时有了这种简写法新增一个 action 的改动量会少很多。3.3 两种写法的选择我在实际项目里的习惯是老代码用 connect 的就继续维护除非要重写需求否则不折腾。原因很简单connect 写法的重构风险高高阶组件嵌套层级多了以后可读性确实会下降。新项目我几乎不用 connect直接用 hooks 写法代码量至少少三分之一心智负担也轻。下面这张表格是我经常用来给团队新人做对比的对比维度connect 写法Hooks 写法读取状态mapStateToPropsuseSelector派发动作mapDispatchToProps / 对象简写useDispatch组件结构高阶组件包裹原组件保持纯展示原组件内部直接使用学习成本偏高要先理解高阶组件较低接近原生 hooks 习惯新项目推荐度不推荐强烈推荐4. 换用 HooksuseSelector 与 useDispatch 的推荐姿势Hooks 出现之后react-redux 在 7.1 版本推出了 useSelector 和 useDispatch操作简洁很多新项目建议直接用这套。useSelector 用来从 store 里读取状态片段useDispatch 用来获取 dispatch 函数。4.1 Hooks 写法的基本示例用前面同样的 Counter 组件做对比你会发现直观很多import { useSelector, useDispatch } from react-redux; function Counter() { const count useSelector((state) state.counter.count); const dispatch useDispatch(); const increment () dispatch({ type: INCREMENT }); const decrement () dispatch({ type: DECREMENT }); return ( div p当前值{count}/p button onClick{increment}1/button button onClick{decrement}-1/button /div ); } export default Counter;useSelector 接收一个选择器函数这个函数接收整个 state返回你想读取的部分。useDispatch 返回的就是 store.dispatch直接调用它派发 action。相比 connect 那一套不需要配置 mapStateToProps也不需要理解高阶组件前提依然只有一个组件在 Provider 内部。4.2 useSelector 的浅比较陷阱一个非常容易被忽略的细节是useSelector 的返回值必须是稳定的。react-redux 内部在派发 action 后会对每个 useSelector 的返回结果做浅比较来判断组件是否需要重新渲染。什么是浅比较简单说就是 比较。你返回的是字符串、数字这类基础值只要值没变浅比较结果就是相等的不会重复渲染。但如果你在 useSelector 里写了类似这种const user useSelector((state) ({ name: state.user.name, age: state.user.age, }));每次选择器执行时都会生成一个新对象即使 name 和 age 都没变对象的引用也是新的浅比较结果不相等组件就会在每次 action 派发后重新渲染。这个问题在真实项目里太常见了经常表现成我只是更新了别的模块的状态怎么这个页面也闪了一下。4.3 两个解决过度渲染的办法解决办法有两个。第一个是拆细选择器一次只选一个基础值const name useSelector((state) state.user.name); const age useSelector((state) state.user.age);第二个是使用 react-redux 提供的 shallowEqual 作为 useSelector 的第二个参数import { useSelector, shallowEqual } from react-redux; const user useSelector((state) ({ name: state.user.name, age: state.user.age, }), shallowEqual);shallowEqual 会比较对象的第一层字段逐项做 判断。上面例子里 name 和 age 都没变即使每次生成新对象shallowEqual 也会判定没有变化组件不会重复渲染。注意它只比较第一层如果字段里嵌套了多层对象还是可能出问题。connect 其实也有类似的坑mapStateToProps 如果每次执行返回新对象同样可能引发多余的渲染。只是 connect 内部做了更多优化加上很多人习惯在 mapStateToProps 里直接返回基础值所以问题表现得没有 hooks 写法那么明显。这也是我面试候选人时喜欢追问的点能把这个细节讲清楚的人说明真的独立排查过问题。注意不要在 useEffect 的依赖数组里直接放一个 useSelector 返回的新对象或新数组。比如const list useSelector((state) state.list.filter(...))filter 每次生成新数组配合 useEffect 使用很容易造成副作用反复触发严重时就是死循环。5. 异步请求怎么办redux-thunk 中间件实战上面聊的都是同步场景。真实项目里最棘手的其实是异步状态管理请求 loading、请求成功、请求失败这三个阶段谁都会遇到。有人会想我在组件里用 async/await 拿到数据后再 dispatch 一个同步 action可行吗答案是可行写法也简单。但你要明白一个底层限制action 本身只能是一个普通对象不能是一个函数。如果你 dispatch 一个函数过去reducer 拿到手的就不是一个带 type 的对象它根本无从处理。5.1 为什么 action 不能是异步的这个限制的根源在于 Redux 的设计哲学。reducer 是纯函数必须接收 state 和 action 两个普通参数不能在里面等网络请求。而 dispatch 这个动作本身原本也只是把 action 同步交给 reducer 的通道。Redux 想要保持整个数据流的可预测性就必须把异步逻辑放到 reducer 之外。那放到哪里放到中间件里。中间件是 Redux 的扩展机制它包装在 dispatch 外层让你能在 action 到达 reducer 之前拦截、加工甚至发起异步操作。redux-thunk 是最常用的中间件它的核心思路很朴素当 dispatch 收到的参数是函数而不是普通对象时就执行这个函数并把 dispatch 和 getState 传进去。这样异步逻辑就有地方写了而且最终还是要 dispatch 同步 action 来真正更新状态。5.2 配置 redux-thunk先安装依赖然后在创建 store 时应用中间件npm install redux redux-thunk react-reduximport { createStore, combineReducers, applyMiddleware } from redux; import thunk from redux-thunk; import counterReducer from ./counterReducer; import userReducer from ./userReducer; const rootReducer combineReducers({ counter: counterReducer, user: userReducer, }); const store createStore(rootReducer, applyMiddleware(thunk));applyMiddleware 是 Redux 自带的函数用来把中间件组合起来。多个中间件时执行顺序就是传入参数的顺序。大多数项目先把 redux-thunk 接上后面再做请求日志、错误上报这类中间件时自然就知道怎么叠加了。5.3 异步三板斧开始、成功、失败一个典型的异步例子是拉取用户信息。action creator 返回一个函数函数内部先派发一个开始请求的 action然后等待网络结果成功或失败后再派发对应的 action// userActions.js export function fetchUser(id) { return async (dispatch) { dispatch({ type: FETCH_USER_START }); try { const response await fetch(/api/users/${id}); const data await response.json(); dispatch({ type: FETCH_USER_SUCCESS, payload: data }); } catch (error) { dispatch({ type: FETCH_USER_ERROR, payload: error.message }); } }; }组件里这样使用import { useDispatch, useSelector } from react-redux; import { fetchUser } from ./userActions; import { useEffect } from react; function UserProfile({ userId }) { const dispatch useDispatch(); const user useSelector((state) state.user.data); const loading useSelector((state) state.user.loading); const error useSelector((state) state.user.error); useEffect(() { dispatch(fetchUser(userId)); }, [dispatch, userId]); if (loading) return div加载中.../div; if (error) return div加载失败{error}/div; if (!user) return null; return ( div h2{user.name}/h2 p{user.email}/p /div ); }对应的 reducer用的是开始、成功、失败三段式// userReducer.js const initialState { data: null, loading: false, error: null, }; function userReducer(state initialState, action) { switch (action.type) { case FETCH_USER_START: return { ...state, loading: true, error: null }; case FETCH_USER_SUCCESS: return { ...state, loading: false, data: action.payload }; case FETCH_USER_ERROR: return { ...state, loading: false, error: action.payload }; default: return state; } }这套模式是所有异步状态管理的基础。loading、error、data 三个字段放在一起组件根据它们渲染不同状态。我见过很多新手只在 store 里存了 dataloading 和 error 都丢在组件内部用 useState 管理结果请求一多每个组件都要自己处理一遍代码重复严重。把这三种状态统一收到 reducer 里管理后续无论加重试、加缓存都会容易很多。Redux Toolkit 里的 createAsyncThunk 已经把这段流程封装得更简洁了但理解了 thunk 的底层原理再看任何异步状态库都能很快上手。6. 日常开发里最容易踩的坑与我的规避方案最后说说我在多个项目里反复遇到的坑。这些坑都不深但真的很能卡时间。有些是我自己踩的有些是我帮别人排查过的按出现频率排下来基本就是下面这几个。6.1 reducer 里直接改了原 state新手最常见的错误是在 reducer 里写 state.count 1 然后 return state。这样做了之后有时候页面会更新有时候不更新完全看 React 渲染机制的脸色。原因在于Redux 判断状态是否改变靠的是引用比较。你返回同一个对象引用react-redux 认为什么都没变自然不会触发更新。所以永远记住这句话reducer 里不要修改原 state一律通过展开运算符创建新对象。如果状态嵌套很深展开三四层的代码确实很痛苦例如case UPDATE_PROFILE: return { ...state, user: { ...state.user, profile: { ...state.profile, name: action.payload, }, }, };这种代码很难读但现实业务里又经常遇到。我的建议是优先考虑状态扁平化把嵌套结构拆平比如用 userId、profileName 这种并列字段替代 user.profile.name 的三层嵌套。实在拆不平再考虑引入 immer 这类不可变数据工具库它允许你用看起来像可变的写法操作一个 draft最后自动生成一份新的不可变对象。注意用了工具库不是可以不创建新对象恰恰相反它存在的目的就是帮你更省力地创建新对象。6.2 把不该放进 Redux 的状态塞进去很多团队一上来就严格要求所有状态都要放到 Redux 里这是一个非常错误的方向。对话框的打开关闭、输入框的临时值、当前选中的 tab这些状态如果也放 Redux每次输入一个字符都要走一遍全局 action 派发流程所有订阅相关状态的组件跟着一起比较性能消耗完全不值得。这类局部状态就应该放在组件内部用 useState 管理。我判断要不要进 Redux 的方法特别朴素这个页面关掉再打开状态还需不需要保留需要保留说明它跨会话或者跨页面放 Redux不需要保留说明它就是临时的交互状态放组件里就够了。6.3 useSelector 返回新引用导致的异常这个坑在上面已经提过原理我再说一个真实场景。有个同事写了一个列表筛选功能代码是这个画风const visibleList useSelector((state) state.list.items.filter((item) item.visible) );filter 每次返回新数组组件每次 action 后都会重新渲染这还在其次。他还在 useEffect 里依赖这个 visibleList用来发一个统计请求结果每次渲染都触发一次请求接口被打爆了都不知道怎么回事。遇到这种需要从现有状态派生新数据的情况正确做法是用 reselect 的 createSelector 做记忆化。它会把选择器的输入和输出缓存起来只有依赖的状态真的变化时才计算新的派生数据并返回新引用否则直接返回上一次缓存的引用。这样既保证组件拿到的数据是最新的又不会因为每次都生成新引用而引发无谓的重渲染。6.4 dispatch 之后马上读状态读到的是旧值还有一个典型问题在事件处理函数里 dispatch 一个 action然后下一行代码立刻读取某个状态发现还是旧值。要知道dispatch 返回后reducer 可能已经执行完了但 React 组件的重新渲染是异步调度的不会立刻反映到当前调用栈里。如果你真的需要在 action 完成后拿到结果有两条路可走。一条是让 thunk 返回 Promise比如function updateUser(data) { return async (dispatch) { dispatch({ type: UPDATE_USER_START }); const res await fetch(/api/user, { method: POST, body: JSON.stringify(data) }); const result await res.json(); dispatch({ type: UPDATE_USER_SUCCESS, payload: result }); return result; }; }组件里可以await dispatch(updateUser(data))拿到请求结果后再做后续逻辑。另一条是把需要的结果放进 action.payload 里由组件通过订阅状态的方式在渲染时获取。不要试图同步轮询状态值方向就错了。6.5 我现在的推荐配置文章最后分享一个我目前在新项目里比较固定的模板。状态管理依赖就是下面这组React 18、react-redux 8、redux-thunk。写 reducer 时模块拆开每个模块一个文件用 combineReducers 合并。组件侧全部用 useSelector 和 useDispatch选择器尽量选择基础值复杂派生一律用 reselect 的 createSelector 缓存。如果你是从零开始搭项目我建议去看一下 Redux Toolkit它内置了 configureStore、createSlice、createAsyncThunkreducer 样板代码能少写一大半而且默认集成了一些基础中间件。react-redux 依然是它的 React 绑定层本文讲的 Provider、useSelector、useDispatch 这些核心语法在 Toolkit 项目里同样适用只是 store 的创建方式会换成 configureStore。先把这些最基本的机制吃透后面不管项目升级到哪种方案你都不会慌。