
第五天算是React学习路上第一个真正意义上的“分水岭”。前四天你把JSX、组件、props、事件处理这些基础工具摸了个遍做一个能点击、能切换的小页面已经不成问题。但只要你手头有一个稍微像样的需求——比如从后端拉数据、多个组件共享同一个值、表单联动更新——你大概率会卡在同一个地方状态到底该怎么管才不乱这期内容我打算一次性把状态管理这条主线讲透。不搬空概念直接按照实际开发顺序来先搞懂useState卡在哪再上手useReducer理清复杂逻辑然后用Context解决跨组件传值最后用useEffect把接口数据接进来。末尾我会把这些知识点揉进一个完整的小项目中做完它你会明显感觉到自己能写的代码上了一个台阶。这篇文章适合已经了解React基础语法、准备开始做真实项目的初学者。今天的内容会比前四天更密建议留出整块时间跟着代码手敲一遍别只看不练。1. 回顾状态管理为什么是第五天的分水岭1.1 前四天的能力边界前四天的学习轨迹基本是JSX语法、函数组件与类组件、props传值、事件绑定。这套组合拳能解决的问题是“静态页面 简单交互”。比如写一个计数器点一下加一写一个Tab切换点哪个显示哪个。这些场景下组件内部自己维护一个状态就够用不需要考虑数据从哪来、怎么同步、别的地方要不要用它。但真实项目远比这复杂。拿一个最常见的后台管理列表来说你需要管理员登录态、搜索关键词、筛选条件、当前页码、选中项、表格数据、加载状态这些数据分布在多个组件里头部搜索栏要改关键词、侧边栏要改筛选条件、表格主体要展示数据、分页器要改页码。任何一个数据发生变化其他组件可能都要跟着变。如果每个组件只管自己的useState就会出现一个很尴尬的局面数据在多个组件里各存一份彼此不知道对方什么时候更新。你想让搜索框和表格联动然后从组件A传props到BB再传给C链条一长代码立刻变得僵硬难维护。1.2 数据流的方向决定了状态的设计方式React官方推荐的是单向数据流数据从父组件向下传给子组件子组件通过回调把更新指令传给父组件再由父组件修改状态后重新渲染。这套规则本身非常清晰但初学者容易忽略一个前提——数据放在哪一层直接决定了组件之间的耦合程度。我在带A同学做项目时就经常看到这样的代码App组件里放了五六个useState然后一层层往下传传到最后一级子组件时props里已经混进来四五个跟这个子组件完全无关的值。组件确实能跑但每次都把所有状态重渲染一遍性能问题先不提代码读起来就是灾难。所以第五天要解决的问题清单很清楚什么时候把状态提升到公共父组件、什么时候把多个关联状态合并成一个reducer、什么时候用Context跳过无意义的层层传递、什么时候用副作用去拉外部数据。这四个问题想明白了状态管理就算真正入了门。2. useReducer当useState不够用的时候2.1 useState的局限在哪里先从最基础的useState说起。它的用法你已经熟悉const [count, setCount] useState(0)。状态一多问题就来了。假设你要管理一个表单的完整状态用户名、邮箱、密码、确认密码、验证码、是否同意协议、提交中标志、错误信息。用useState写出来大概是这种感觉const [username, setUsername] useState() const [email, setEmail] useState() const [password, setPassword] useState() const [confirm, setConfirm] useState() const [code, setCode] useState() const [agree, setAgree] useState(false) const [submitting, setSubmitting] useState(false) const [error, setError] useState()这还只是定义阶段。等到处理用户输入时你要为每个字段写set函数提交时要同时校验多个字段、逐个修改好几项状态比如验证失败就同时设置error和submitting为false。代码开始变得散乱而且容易出现漏改状态导致的bug。这背后其实是一个逻辑问题多个状态之间存在联动关系但useState把它们拆成了独立的个体维护联动要靠开发者自己记住“改了A也要改B”。人脑记不住太多这样的约束代码一旦超过几十行就失控了。2.2 useReducer的核心思路把状态变化收拢到一处useReducer的思路跟useState完全不同。它不再为每个状态字段提供独立的setter而是把整块状态对象打包所有改动都通过一个reducer函数来统一处理。reducer接收当前状态和action返回新的状态const initialState { username: , email: , password: , confirm: , code: , agree: false, submitting: false, error: } function formReducer(state, action) { switch (action.type) { case FIELD_CHANGE: return { ...state, [action.field]: action.value } case SUBMIT_START: return { ...state, submitting: true, error: } case SUBMIT_FAIL: return { ...state, submitting: false, error: action.error } case SUBMIT_SUCCESS: return { ...state, submitting: false } default: return state } }这样设计的好处非常实际所有状态变化规则都集中在formReducer一个函数里以后要加校验规则、加字段、改提交逻辑都只改这个函数不需要去组件里翻找分散的set函数。组件内部只需要调用dispatchconst [state, dispatch] useReducer(formReducer, initialState) const handleChange (e) { dispatch({ type: FIELD_CHANGE, field: e.target.name, value: e.target.value }) }你会发现组件的逻辑一下子变薄了它只负责“发出指令”不负责“执行规则”。指令具体怎么改变状态是reducer的事。这跟真实团队分工很像组件是前台前台接线员reducer是后台业务处理部门。2.3 用表格看清useState和useReducer的适用边界说实话这两种方式没有绝对的好坏选错了才会难受。我一般按下面这张表来判断因素useStateuseReducer状态字段数量少1~3个多4个以上状态更新逻辑简单赋值即可有嵌套规则、联动判断状态之间关联相互独立一个操作要改多个字段团队可维护性组件内部自给自足逻辑集中方便测试和复用调试体验DevTools直接看各state配合action type更容易回溯操作简单说如果你发现自己在一个事件处理函数里连续调用了两三次setState那就是该考虑useReducer的信号。每次改动状态都要走同一个函数后面出bug时你只需要问一个问题这个action是哪里dispatch出来的定位速度会快很多。3. Context跨组件数据共享的实用方案3.1 prop drilling是怎么发生的跨组件传值这个问题前四天你多半已经遇到过了。A组件把props传给BB传给CC传给了D最后D里面只用了一个值但中间三层组件全都得帮着把这个prop转发一遍。代码里写起来就是一连串的重复透传function App() { const [user, setUser] useState(null) return Header user{user} / } function Header({ user }) { return UserInfo user{user} / } function UserInfo({ user }) { return p{user?.name}/p }可能有人觉得三层不算什么那十层呢真实系统里组件嵌套深度是非常夸张的某个全局状态可能同时被十几个不相邻的组件使用。每层转发一次代码里就会多出一堆跟本层业务无关的中间props。这个现象有个专业名字叫prop drilling翻译过来就是“属性钻洞”听起来就透着一股难受劲。3.2 Context的三个关键环节创建、提供、消费React给出的方案是Context。它相当于在组件树的外面拉了一根“数据总线”某个组件把值放上去另外几个组件直接取用中间不经过任何转发。第一步创建Contextconst UserContext createContext(null)第二步在组件树上层用Provider包裹把需要共享的值放进去function App() { const [user, setUser] useState(null) return ( UserContext.Provider value{{ user, setUser }} Header / MainContent / Footer / /UserContext.Provider ) }第三步在任何需要的地方用useContext消费function UserInfo() { const { user } useContext(UserContext) return p{user?.name}/p }整个链路非常清晰。Provider的value变化时所有消费这个Context的组件会自动重新渲染不需要额外写事件通知。3.3 使用Context前必须先接受的三个规矩Context不是银弹我用它踩过一些坑这里把规矩一次说清楚。第一不要滥用Context。全局只有一个顶级状态的情况极少项目一大各种Context层叠包裹组件树会变得非常臃肿而且任何子组件都不会意识到自己依赖了一个全局状态代码可读性反而下降。我自己的习惯是只有被多个互不嵌套的组件共享的数据才放进Context比如登录态、主题、语言、权限。页面内部的一次性状态该放哪就放哪别动辄上Context。第二Provider的value要控制好。在Provider的value里写对象字面量比如value{{ user, setUser }}会导致每次父组件渲染时创建新对象引用所有消费方全部跟着重渲染。不做优化时问题不大组件多了就会卡。更好的做法是用useMemo把这个值缓存起来或者尽量只放那些真正需要共享的原子状态。第三Context不是状态管理工具。它不是用来替代useReducer或useState的而是用来解决传播路径问题的。完整的落地套路通常是useReducer管状态本身Context管把状态送到各组件手里。两者配合状态逻辑和传播问题一起解决。4. useEffect与异步数据请求4.1 谁都绕不过去的接口数据前四天你碰到的数据基本都是组件内部写死的常量。真实项目里没有这好事数据几乎全部来自后端接口。React组件渲染是同步的接口请求是异步的这中间的对接工作就得交给useEffect。最基本的写法大家都见过useEffect(() { fetch(/api/users) .then((res) res.json()) .then((data) setUsers(data)) }, [])这里需要想明白的是第三个参数空数组代表什么。它告诉React这个副作用只在组件挂载后执行一次后续渲染不再触发。如果你不小心省略了依赖数组副作用会在每次渲染后都执行一遍表现是Fetch请求无限循环页面疯狂刷新数据。4.2 依赖数组的完整理解依赖数组是useEffect最容易出错的地方因为它要求你准确判断“哪些外部值变化时需要重新执行副作用”。举例来说useEffect(() { fetch(/api/users?page${page}keyword${keyword}) .then((res) res.json()) .then((data) setList(data)) }, [page, keyword])这里把page和keyword放进依赖数组意义就是页面变化或关键词变化时重新请求数据。省掉任何一个请求就会用到过期值全放进去又会让每次输入都发请求性能很差。实际项目中这种场景要配合防抖来做现场演示太碎了今天就不展开说你只要先建立起“依赖决定执行时机”这个核心认知就够了。还有一个大家很容易忽视的细节副作用函数里引用了某个值却没有写进依赖数组React会在编译期通过lint插件提醒你。我见过不少人直接忽略这个警告最后踩到因为闭包捕获旧值导致的诡异bug。老老实实把依赖补齐或者用函数式更新绕过外部依赖都是干净的做法。4.3 请求竞态新手最容易忽略的深坑异步请求中有个很反直觉的问题叫做竞态。想象一下你输入了一个关键词发起了请求A一秒钟后你又改了关键词发起了请求B。如果请求B比请求A先返回页面倒是正常但网络不是排队来的如果请求A晚于请求B返回那么最后渲染的数据就是旧的等于页面显示的是上一次搜索的结果。我在指导模拟项目X的联调阶段时就遇到过一模一样的现象用户快速切换筛选条件表格内容偶尔跳到上一次条件的数据后来定位就是竞态问题。基础的解决手法是在effect里加一个取消标志useEffect(() { let cancelled false fetch(/api/users?keyword${keyword}) .then((res) res.json()) .then((data) { if (!cancelled) { setList(data) } }) return () { cancelled true } }, [keyword])cleanup函数在下次执行effect前会先执行把上一次请求的cancelled置为true这样旧请求即使先返回也不会更新状态。这套写法在今天最后的小项目里也会用到。要注意这里说的是基于fetch的常规方案。现代浏览器里也可以考虑用AbortController真正取消网络请求但cancelled标志的写法更通用、更容易理解作为起步足够了。5. 完整项目实战构建一个车队调度模拟面板5.1 项目需求梳理前面的抽象概念讲再多不如亲手做一遍。这次我挑了一个既贴合现实业务、又不至于太复杂的小项目一个车队调度模拟面板。假设场景是这样一家配送公司有一个车队后台需要列出所有在途车辆展示每辆车的车牌、司机、当前位置、状态行驶中、停靠、故障支持按状态筛选支持搜索车牌或司机姓名点击车辆可以查看详细运行信息。后端接口是模拟数据延迟随机方便我们复现真实网络环境。这个项目需要的数据有车辆列表、筛选状态、搜索关键词、加载状态、选中的车辆详情。按照前面的内容这几块正好覆盖useReducer、Context、useEffect三种工具。5.2 组件结构与状态设计组件结构我先拆出来App ├── FilterBar搜索框 状态筛选 ├── VehicleList车辆列表 │ ├── VehicleCard单辆车卡片 │ └── Pagination分页可省略先做简化版 └── VehicleDetail点击车辆后的详情面板状态设计上筛选条件放在FilterBar和VehicleList的公共父组件App里。但考虑到FilterBar和VehicleList互不嵌套筛选条件这两个组件都要用我决定把筛选状态放进Context由FilterBar修改VehicleList读取。同时车辆列表数据和加载状态属于比较繁琐的联动逻辑请求开始要把loading置为true、清除error请求成功要填列表、关loading请求失败要填error、关loading。这种多字段联动正是useReducer的用武之地。5.3 用useReducer管理列表数据状态先把列表相关的状态集中到一个reducer里const initialState { vehicles: [], loading: false, error: } function listReducer(state, action) { switch (action.type) { case FETCH_START: return { ...state, loading: true, error: } case FETCH_SUCCESS: return { ...state, loading: false, vehicles: action.vehicles } case FETCH_FAIL: return { ...state, loading: false, error: action.error } default: return state } }然后在App组件里const [state, dispatch] useReducer(listReducer, initialState)后续发起请求时三行代码就能完成一次完整的状态流转dispatch({ type: FETCH_START }) // 请求成功 dispatch({ type: FETCH_SUCCESS, vehicles: result }) // 请求失败 dispatch({ type: FETCH_FAIL, error: message })你不用再担心某个分支漏掉了loading状态因为状态变化规则全部在reducer里写死了。5.4 用Context传递筛选条件接下来是筛选条件。我把筛选状态和dispatch方法放进Contextconst FilterContext createContext(null) function App() { const [filter, setFilter] useState({ status: 全部, keyword: }) return ( FilterContext.Provider value{{ filter, setFilter }} FilterBar / VehicleList / VehicleDetail / /FilterContext.Provider ) }FilterBar里直接读setFilter改条件VehicleList里读filter去决定请求参数。中间不用绕一层层props。这就是Context省去的麻烦。5.5 用useEffect接数据并处理竞态核心逻辑集中在VehicleList组件里监听filter变化发起请求渲染列表。function VehicleList() { const { filter } useContext(FilterContext) const { state, dispatch } useVehicleList() useEffect(() { let cancelled false dispatch({ type: FETCH_START }) fetch(/api/vehicles?status${filter.status}keyword${filter.keyword}) .then((res) res.json()) .then((data) { if (!cancelled) { dispatch({ type: FETCH_SUCCESS, vehicles: data }) } }) .catch((err) { if (!cancelled) { dispatch({ type: FETCH_FAIL, error: err.message }) } }) return () { cancelled true } }, [filter.status, filter.keyword, dispatch]) if (state.loading) return p加载中.../p if (state.error) return p出错了{state.error}/p return ( div {state.vehicles.map((vehicle) ( VehicleCard key{vehicle.id} vehicle{vehicle} / ))} /div ) }我在代码里做了一个简化把dispatch放进了依赖数组。因为React保证dispatch引用是稳定的所以不会引发额外请求。这里有个实战细节值得注意filter是一个对象如果直接把整个filter放进依赖数组那么任何一次setFilter都会触发请求包括只改keyword没改status的情况实际上这是符合预期的。但如果你把对象里的基础值拆开比如filter.status和filter.keyword分开写意图会更明确可读性也更好。我在上面就是采用分开写的做法。VehicleCard内部点击车辆时要展示详情。这个数据不用共享给别的列表我会直接在VehicleDetail组件的effect里根据选中的车辆id去拉取详情接口逻辑完全独立互不干扰。5.6 完整组合最终代码长什么样把上述模块拼到一起后整体数据流是这样的FilterBar修改Context里的filterContext更新触发VehicleList重新渲染VehicleList里的useEffect发现filter.status或filter.keyword变化重新请求列表请求返回后dispatch合并新状态列表渲染用户点击某一辆车VehicleDetail拿到选中的车id拉取详情并展示全套下来useState、useReducer、Context、useEffect各自负责自己最擅长的事。代码结构可以对照下面的目录理解src/ ├── App.jsx ├── contexts/FilterContext.js ├── reducers/listReducer.js ├── components/FilterBar.jsx ├── components/VehicleList.jsx ├── components/VehicleCard.jsx └── components/VehicleDetail.jsx初学者做到这个程度算是迈出了从“写组件”到“写应用”的第一步。你会看到组件本身反而不带多少状态逻辑真正的复杂度被挪进了reducer和context这些基础设施里。6. 常见问题与调试实录6.1 写完useEffect后接口哐哐请求个不停这个问题我几乎每周都会在群里看到一次。现象是页面一打开network里一堆相同请求在飞页面卡到没法看。原因基本都是副作用里用了某个没有稳定引用的函数或对象导致每次渲染都认为依赖变了。比如把dispatch方法放进了依赖数组但dispatch并没有用useCallback包住或者filter.status的值本身每次渲染都在变。排查方法很粗暴先把依赖数组全部拆掉发现问题再用React DevTools检查是哪个值在变。解决方案分两类值真的在变那就做防抖或节流值是稳定引用但写法问题就加useCallback或useMemo包一层。提示React严格模式下开发环境会故意将effect执行两次来暴露潜在问题。看到双倍请求别慌先检查是否开了严格模式再判断是逻辑bug还是环境行为。6.2 dispatch后页面不更新常见于你把reducer写错了最常见是直接修改了原state对象。比如function listReducer(state, action) { state.vehicles action.vehicles return state }这种写法问题很大。React需要的是一个新对象来触发渲染直接把老对象改了再返回React无法感知变化。正确写法必须返回新的引用return { ...state, vehicles: action.vehicles }这里我建议你多练习几次扩展运算符的使用它是React不可变更新风格的基本功。用多了自然熟练。6.3 Context值变了但组件不渲染通常不是Context本身的问题而是Provider的value引用没有变化。比如我在FilterBar里写的是setFilter({ ...filter, status: newStatus })注意虽然对象内容变了但每次调用都生成了新对象消费方应该能感知。真正容易出错的是把setFilter存到某个ref里或者用useMemo缓存了不该缓存的值。检查顺序就两步先确认Provider确实包裹到了所有消费者再确认value的类型不是稳定的副作用。6.4 接口联调时遇到的脏数据还有一种情况很隐蔽接口返回的数据结构跟前端预期不一致。字段名对不上、嵌套层级错位、某些车辆缺少详情字段。前端拿到数据后没有做任何校验直接渲染页面轻则显示一堆undefined重则整个崩溃。我在做模拟项目X时吃过这个亏。后来养成的习惯是所有接口返回的数据必须过一个简单的字段整理函数缺字段就补默认值而不是把原始接口数据直接交给组件。这算是一个联调经验建议你也养成。7. 几个值得长期坚持的实践习惯走到这里Day5的核心内容就结束了。最后再分享三个我认为对之后学习最有帮助的实践习惯。第一个习惯是坚持把状态逻辑从组件里往外抽。刚开始做项目组件里写各种useState和事件处理很爽但随着功能增加组件越来越厚。我现在每写完一个功能都会回头看一眼哪些逻辑其实可以放到自定义hook或者独立的reducer里能不能让组件只负责渲染和发指令。这个习惯让代码可维护性提升非常明显。第二个习惯是学会用React DevTools的Profiler面板。它不是只给高级开发者用的初学者也能从中看到每个组件什么时候因为什么原因重渲染。排查性能问题也好理解状态变化也好都是很直观的入口。第三个习惯是每次加新功能前先动手把数据流画清楚。这里说的画拿纸笔画都行。从初始数据出发标出哪些状态应该放在哪一层、哪些是通过props传递、哪些走Context、哪些靠异步请求获取。画完你会发现写代码变成了填空题难度骤降。我自己在带A同学做第一个完整项目时最大的感受就是React真正的复杂度不在于语法难记而在于数据流的设计。语法是死的背一背就会数据流是活的得靠一遍遍实践才能建立起直觉。Day5的内容量不算小建议你拿这个车队调度项目练手时先不要急着一次做完可以只完成列表渲染和筛选再一点点把详情面板和请求竞态处理加进去。跟着过一遍后面再去学路由、构建工具这些周边会顺手很多。