资讯动态

React状态管理指南:Hooks、性能优化与全局状态选型

发布时间:2026/10/3 9:57:23 来源:尧图企业网站定制
1. 从一个 state 问题开始为什么 React 应用都要过这道坎1.1 我在项目里见过的“状态事故”先讲一个比较典型的场景。需求很简单一个搜索页包含搜索条件、结果列表、分页器、最近搜索记录再加一个“清空条件”的按钮。听起来不难但我接手的时候这个页面里十几个 useState 散落在不同组件里有的状态放在子组件中被父组件通过回调读取有的状态明明只跟列表相关却被提到了页面最顶层导致列表一刷新整页所有输入框、下拉框全都重新渲染一遍。更难受的是不同人写入状态的方式还不一样。有人在 setState 之前先手动复制一份对象做“浅拷贝”有人直接改原对象再 setState 赋值同一个引用结果自然是改了数据但界面纹丝不动。最后还是靠 React DevTools 逐个组件看 props 和 state 的 diff才发现问题都出在“状态该放哪、怎么改、怎么传”这三件事上没想清楚。这类问题在 React 项目里非常普遍。我后来复盘时意识到大部分人并不是不会写 useState而是对“state 的本质”缺少一套自己的判断框架。这篇文章不会讲太理论的源码分析我尽量从实际项目出发把 React 状态与 Hooks 的常见用法、设计思路、性能陷阱和排查手段梳理清楚看完可以马上对号入座去改自己项目里的代码。1.2 状态到底是什么说给“用熟但没想透”的 React 开发先说结论React 里的 state本质上是“用来决定 UI 应该长什么样的数据”。它和普通变量最大的区别在于state 的变化会触发 React 重新执行组件函数然后通过虚拟 DOM 的 diff 去更新真实 DOM。普通变量改了就是改了UI 不会感知state 改了React 就会被“通知”去重渲染。所以设计状态的最底层原则不是我需要存几个变量而是“这个 UI 上有多少种会变化的表现形态我就需要存多少份状态”。这句话看着简单但绝大多数状态事故都源自违背它的两个极端一个极端是“能提就提”把所有可能变化的数据全放在顶层导致重渲染范围扩大另一个极端是“能省就省”把好几个独立变化维度硬塞进同一个对象里结果改一个字段要带着其他字段一起经历 setState代码里到处是展开运算符和深拷贝。另外state 还有一个经常被忽略的属性它是有“身份”的。同一份数据放在不同的层级它的更新成本、传播路径、可维护性完全不同。这也是为什么 React 文档里一直强调“状态提升”和“状态下放”——不是为了让数据从底层能传到顶层而是为了让数据尽可能靠近“唯一需要它变化 UI 的地方”。下一节我就从最基础的 Hooks 讲起先看看单组件内部的状态应该怎么管。2. 先把 Hooks 用明白useState 和 useReducer 的正确姿势2.1 useState 的四个高频细节useState 大概是所有人入门 React 时第一个接触的 Hook但它真正的使用细节很多写了两年 React 的人都不一定完全说清楚。我按照踩坑频率从高到低列几个点第一setState 是异步的而且“异步”不是批处理的副作用而是有意设计。React 会在事件处理函数内部批量收集多个 setState 调用然后统一执行一次更新。这意味着你如果在同一个事件里连续两次 setCount(count 1)count 并不会加两遍。很多人在这写“每日递增”的计数逻辑时容易入坑。解决办法也很简单如果更新需要依赖上一次的值就传函数形式setCount(prev prev 1)React 会在队列里依次调用这个函数绝不会覆盖。第二setState 传对象和传函数的差别不只是习惯而是语义不同。传对象表示“我明确知道新值是什么”传函数表示“新值需要基于旧值计算”。当两个 setState 并发排队时传对象的一方可能覆盖掉前一次更新的结果传函数的一方则安全得多。第三不要直接修改 state 对象。我见过最多的写法错误就是state.list.push(item)后再 setState 同一个 list 引用。React 比较新旧 state 时用的是Object.is引用没变它就会认为没变化直接跳过渲染。就算你再用一次展开运算符生成新对象某些子组件如果依赖旧的深层引用仍然可能不会更新。正确做法是从头到尾保持不变式要修改就生成一个新的数组或对象。第四useState 的初始值可以传一个函数用来做懒初始化。这个细节在计算初始值很昂贵时非常关键比如从 localStorage 读取并解析一段 JSON、或者做一次递归计算。直接传调用结果是每次渲染都会执行那个表达式传函数则只在初次挂载时执行一次。// 不推荐每次渲染都会执行 JSON.parse const [settings, setSettings] useState(JSON.parse(localStorage.getItem(settings))); // 推荐只在首次挂载时执行一次 const [settings, setSettings] useState(() JSON.parse(localStorage.getItem(settings)));2.2 useState 的性能细节该不该分多个 state很多人纠结“一个组件里是多个 useState 还是一个对象 state”。我的判断标准只有一条这些字段是不是总是同时变化。如果两个字段毫无关联一个改变时另一个也必须跟着改变那放同一个对象里没什么问题如果它们各自独立变化那拆开更有利于性能——因为 React 的更新粒度是组件级同一组件内部拆不拆其实不影响重渲染范围但对代码可维护性影响很大。其实在一个组件函数内部无论你拆多少个 useStateReact 最终都会合并到同一次 commit 里所以不会因为拆了就多渲染一次。真正影响渲染性能的是state 被放在了哪个组件层级以及这个状态是不是被 useContext 跨层级订阅了。这一点我会在第三节和第四节重点展开。另外还有个小技巧当一个 state 和另一个 state 是“严格派生关系”时你不该同时存两份。比如列表数据 list 和过滤结果 filteredListfilteredList 完全可以通过 list 和 filterKeyword 计算出来。此时只存 list 和 filterKeyword渲染时直接调用一个计算函数或者用 useMemo 缓存结果。这样避免手动同步两份数据时出现不一致代码也更好维护。2.3 useReducer复杂状态机的轻量解法当组件里有四五个相互关联的状态字段而且变更逻辑复杂比如购物车、多步表单、配置编辑器我会直接换 useReducer。它本质上把“状态的变更规则”从组件函数里抽离出来变成一个纯函数 reducer。这样组件的职责只剩发 action而状态怎么变、由谁变全在 reducer 里逻辑清晰、容易单测。function formReducer(state, action) { switch (action.type) { case SET_FIELD: return { ...state, [action.field]: action.value }; case RESET: return initialState; case SET_ERROR: return { ...state, error: action.error }; default: return state; } } const [formState, dispatch] useReducer(formReducer, initialState);这里注意两点。一是 reducer 必须是纯函数绝对不能在 reducer 里做异步操作、改外部变量、生成随机数否则 React 18 的 Strict Mode 会在开发环境下刻意调用两次 reducer 来帮你暴露非纯函数问题。二是 dispatch 本身是稳定的它不会因为重渲染而改变所以你可以放心地把它作为 props 传给子组件或者放进 effect 的依赖数组。我见过不少团队觉得 useReducer 是给“大项目”用的小页面不值得。实际上 useReducer 的门槛不高比起 useState 它反而更容易让人保持“不可变更新”的习惯因为每次都是基于 action 生成新 state。只要字段之间联动关系超过两三层我就会建议优先考虑 useReducer而不是在 useState 外面套一堆 if-else。3. 跨组件通信useContext 与状态提升3.1 Props drilling 究竟要不要躲跨组件共享状态最粗暴的办法是“状态提升”到共同父组件然后一层层传 props。问题来了当组件层级深、中间组件根本不需要这个数据时这串 props 就成了“穿透式”的代码特别臃肿也容易出错——中间组件哪怕只是转发 props 时少写了一个底下的组件就默默拿不到更新了。这就是大家常说的 props drilling。但我要说句未必受欢迎的话浅层 props 传递其实不丢人反而可能是最清晰的方案。如果只有一两级传递代码一眼能看懂数据从哪来、往哪去我宁愿用 props 而不是引来一个 context。真正需要躲的是深度超过三层、中间有多条分支的“透传”。这时候硬用 props 会让组件接口变脏也让每个中间组件承担了它本来不该关心的转发职责。另一个容易被忽略的问题是状态提升之后如果子组件需要修改父组件的状态通常要再传一个回调函数。这个模式没问题但要注意回调函数的稳定性。每次渲染都新建的普通箭头函数会让那些用 memo 包裹的子组件白白放弃缓存优化。解决办法后面讲先用 useCallback 包一层。3.2 useContext 真正的使用边界useContext 的机制是“订阅”任何调用了 useContext 的组件只要 context 的 value 引用变化就会强制重新渲染。这里的关键在于“引用变化”。很多人写了这样的代码const [user, setUser] useState({ name: 张三, age: 20 }); AppContext.Provider value{{ user, setUser }}每个 Provider 的父组件只要一渲染这里的{ user, setUser }就会生成一个全新对象于是所有消费了这个 context 的组件都会跟着无条件重渲染不管 user 有没有实际变化。这个机理很多新人不知道他们会发现为什么我只是切了个无关的 tab整个页面的所有组件全闪了一遍。所以使用 context 有一个铁律Provider 的 value 要尽量 memo 化至少把稳定不变的函数抽出来能用 useMemo 包整个 value 对象就更好了。const contextValue useMemo(() ({ user, setUser }), [user]);这样一来只有 user 真正变化时value 的引用才变化消费组件才会重新渲染性能表现立刻不一样。3.3 用 context 拆分避免大范围 re-rendercontext 还有另一个反直觉的用法不要只建一个巨大的全局 context把所有状态全塞进去。我在项目里见过一个GlobalContext里面同时放了用户信息、主题、权限列表、通知数量、当前语言几十个字段。它的后果是任何一处改状态全站所有组件重渲染哪怕是不需要这个状态的纯展示组件。排查之后才发现性能问题的根子不是 useEffect 写多了而是单 context 订阅面过大。合理的做法是按“变化频率和业务域”拆多个小 context。比如主题这种极少变化的单独一个用户信息单独一个权限单独一个通知数量这种高频的再单独一个。每个 context 只服务一小撮真正需要它的组件。把 context 想成“广播频道”每个频道只推送一类消息订阅者按需收听而不是大家都挤在一个全频道广播里。我自己的实操建议是上下文只放“通用但稳定”的数据比如主题、登录用户、当前语言、权限集合而“请求列表的数据”“表单草稿”“临时筛选条件”这些经常变化而且只被局部组件消费的状态尽量不要丢进全局 context它们更适合留在各自页面内部实在需要跨页面共享时再用专门的状态管理库或持久化方案。4. 渲染性能状态在哪重渲染就在哪4.1 状态拆分与组件粒度的关系React 的重渲染有一个朴素但核心的规则你在哪个组件里 setState就从哪个组件开始向下重新渲染。父组件的状态一变所有子组件都会跟着再走一遍函数体除非子组件被 memo 包裹并且 props 引用没变。这是无数项目“页面卡顿”的直接原因——不是列表渲染慢而是大范围无用的子组件重渲染造成的。所以性能优化的第一手段不是 useMemo 和 useCallback而是把状态下沉到“最需要使用它的最小组件单元”。比如一个表格组件只有“选中行”这个状态会被表头复选框和操作栏用到那就把这状态放在表格页面内部别提到整个路由级布局上。同理如果某个子组件只依赖自己的局部状态就该把它作为一个独立组件封装出来父组件的重渲染就影响不到它。这其实是让组件体系回归“单一职责”的过程。我在重构项目时经常做一件事先画一张 props 依赖图凡是不含任何 props 却因为父级重渲染而跟着闪的子组件优先把它们提出来内部用自己的 useState 管理私密状态。这个动作对性能的改善比任何性能 API 都大。4.2 useMemo 和 useCallback 的适用场景与误用场景useMemo 是用来缓存“计算结果”的useCallback 是用来缓存“函数引用”的。很多人把这两个 Hook 当成万能性能药见一个组件就套一层这是我在评审代码时最头疼的事。因为滥用它们本身也有成本——每次 Hook 都要做依赖数组的比较还要额外占用内存保存旧值。相比它们省下的那点计算可能并不划算。我的判断口径很简单如果计算本身很轻比如渲染一个数组的 map 操作普通写就行不需要 useMemo。如果计算涉及大数组遍历、复杂排序、格式化大文本并且它会随着重渲染反复执行用之。如果函数被传给了被 memo 包裹的子组件而且要稳定作为子组件 effect 依赖用 useCallback 包一层。如果函数只用于内部事件处理不传子组件就不用包。这里最重要的场景是“配合 memo 使用”。memo 的工作机制是比较 props 的引用如果父组件每次渲染都给子组件传一个全新函数那 memo 等于白白包了子组件每次都重新渲染。所以 useCallback 真正的价值是配合 memo 一起在“函数作为 props 传给子组件”时发挥作用。const handleSave useCallback(() { saveDraft(formState); }, [formState]);4.3 优化里的常见陷阱经验里最容易翻车的有三个地方。第一个是依赖数组漏项。useCallback 缓存了函数如果函数内部读取了某个 prop 或 state但依赖数组里没写那函数永远是旧值子组件点击后用的是过时数据。这个 bug 特别隐蔽因为表面看起来代码没报错、也没崩溃就是功能不对。之前有个同事把依赖数组留了空结果左侧菜单切换了右侧详情一直显示第一次的数据排查了半天才找到原因。根治办法是别瞎优化该依赖就写依赖让 Hook 重新生成函数没关系的。第二个是useMemo 返回的不是原始值。useMemo 缓存的是上一次计算出来的结果如果你在里面生成了对象或数组返回的就是一份缓存引用。这个引用一旦改变所有依赖它的 memo 子组件都会失效。有时你以为 useMemo 能阻止子组件重渲染但它返回的 object 在新一轮计算里其实变了引用效果适得其反。真要缓存引用就确保返回值也是 memo 的或者干脆让计算放回组件内部别过度分层。第三个是Optimization 导致的“延迟更新”。如果 useEffect 的依赖数组里放了一个 useCallback 函数而这个函数又依赖另一个变化频繁的 state那么每次 state 变化effect 都会跑——这本质上是绕了个大圈性能反而更差。遇到这种情况我会考虑把要执行的逻辑直接放进 reducer 或 ref而不是硬套 useCallback。5. 自定义 Hooks把状态逻辑抽出去5.1 设计一个 useFetch自定义 Hook 是 React 里最容易被低估的能力。它不是什么高级黑魔法本质就是把“和使用 useState、useEffect、useRef 相关的逻辑”提取成一个普通函数让多个组件可以复用同一套状态逻辑。我几乎在每个项目里都会封装一个 useRequest 或 useFetch统一处理 loading、error、data 三个基本状态。先说最简单的版本function useFetch(url) { const [data, setData] useState(null); const [loading, setLoading] useState(true); const [error, setError] useState(null); useEffect(() { let ignore false; setLoading(true); fetch(url) .then(res res.json()) .then(json { if (!ignore) { setData(json); setError(null); setLoading(false); } }) .catch(err { if (!ignore) { setError(err); setLoading(false); } }); return () { ignore true; }; }, [url]); return { data, loading, error, setData }; }注意这段代码里的ignore标志位它是用来规避“竞态”的。组件可能卸载了或者 url 变化导致上一次请求的响应在本次请求之后返回此时 setState 会导致内存泄漏或数据错乱。ignore 标志可以在卸载或依赖变化时把上一次请求的 setState 屏蔽掉这是我在实战中必须写上的细节。很多人只写一个简单的 fetch线上偶发“页面数据被旧请求覆盖”的问题很可能就是少了这一步。5.2 如何让自定义 Hook 可复用自定义 Hook 看起来只是一个函数但它真正的复用价值不在于把代码复制粘贴少一点而在于把“状态 生命周期逻辑 出参/入参约定”打包成一个语义清晰的黑盒。要让 Hook 真的可复用我有几个习惯一是入参不要给“数据”而是给“变化来源”。比如 useFetch 的入参是 url 而不是已经处理好的 promise因为 promise 每次渲染都是新的会导致 effect 反复执行传入 url 这种原始值依赖数组才好判断。二是返回的“更新函数”要稳定。像上面这个 useFetchsetData 是 useState 自带的 setter稳定可靠但如果 Hook 里自建了 refresh 函数最好用 useCallback 包一层否则调用方把它放到 useEffect 里又会触发无限循环。三是 Hook 内部放一个“状态机枚举”比散落的多个 boolean 好用。用{ status: idle | loading | success | error }代替单纯的 loading 和 error 两个字段调用方的逻辑会清晰很多不容易出现“loading 和 error 同时为 true”的脏状态。5.3 命名习惯与依赖关系自定义 Hook 的命名必须以 use 开头这个不是闲聊而是构建工具和 ESLint 插件判断“这是 Hook”的依据。如果你把 Hook 命名成fetchUser、getListData那 React 内部的 dispatcher 状态就完全乱了因为解析器会在函数内部无条件调用 useState 或 useEffect而 React 要求 Hooks 调用必须在组件函数或自定义 Hook 的顶层。ESLint 的react-hooks/rules-of-hooks规则就是靠函数名以 use 开头来识别这类文件的。另一个关键是依赖关系。自定义 Hook 内部的 effect 依赖尽可能收口到“传入参数和内部自有状态”。如果调用方传入一个变化频繁的对象会让 Hook 内部的 effect 频繁执行。这时候可以考虑把对象拆成原始值传入比如传 id 而不是传 user 对象。实战中“同一个对象引用因为 setState 不变化但 effect 一直跑”的问题多半就出在这里把对象转成原始值参数是最直接的解法。6. 扩展本地状态、全局状态与缓存的分界6.1 到底什么时候该引入全局状态本地状态和全局状态的标准边界不是“距离有多远”而是“更新时的传播范围你愿不愿意接受”。全局状态一旦变化理论上任何订阅者都会被通知。这意味着你把一个状态变全局就是在宣布“我不在乎它变化时全站重渲染”。所以全局状态只应该放“真的全站都需要看同一份数据”的东西当前登录用户、权限、主题、语言。如果只是两个兄弟组件共享一份筛选条件优先考虑状态提升到它们的最近公共父组件、或者用自定义 Hook 封装模块级变量如果提升后层级太深再考虑把这份状态放到一个 context 或状态管理库里。多数人习惯一遇到跨组件就上 Redux/Zustand结果页面一多全局 store 里什么都有调试起来根本不知道哪个 action 改了哪个字段反而不如各自维护小状态。我自己的经验是先试着不用全局状态库过一遍需求发现传参链路太长、可维护性太差时再引入。因为全局状态库不是银弹它要处理异步流、持久化、模块拆分维护成本比 useState 高一个量级。一个简单的页面真没必要。6.2 状态库选型思路Redux、Zustand、Jotai、MobX选状态库这件事很多团队就是凭“团队熟悉”来决定很少从“状态模型”角度想。我核心关注三个维度状态是否重度嵌套、更新是否高频、是否需要时间旅行调试。基于这几个维度说下我的个人感受Zustand轻量、上手快、支持选择器订阅。适合中小型项目也适合 React 之外的环境。它的写法非常像“带监听功能的模块变量”我很喜欢。Redux Toolkit适合大型项目、多人协作、需要严格规范 action 流和数据流转的团队。Redux 的学习曲线主要来自概念不是库本身。RTK 已经把样板代码压得很低了。Jotai原子化状态适合状态之间依赖链复杂的场景。如果你喜欢 useState 但需要跨组件它是个很自然的升级。MobX响应式风格写起来像“改普通对象”。适合习惯了可变数据的团队但调试和追溯时没有 Redux 那么透明。另外强调一个关键点别把服务端数据全塞进本地状态库。服务端状态是指从接口拿到的列表、详情、配置它们有自己的加载、失败、过期、缓存逻辑本质上跟“用户点击产生的本地 UI 状态”不是一回事。硬把它们混在一起会让 store 越来越大缓存清理逻辑却永远缺失。这也是下一节要说的重点。6.3 Server State用专门的方案还是自己处理关于服务端数据我近几年强烈建议直接引入 TanStack Query 或 SWR 这类专门的请求缓存库。它们帮你自动管理缓存、重新验证、失效、乐观更新你只需要关心自己的业务逻辑。普通手写 fetch 加 useState 的套路在项目规模变大后几乎必出“数据过期”或“重复请求”的问题。我自己曾经在一个后台系统里为了淘汰旧的“全局请求状态”写了个 service 层去管理每个模块的请求 promise然后组内再用 dispatch 同步到 store。到头来发现这个 service 层做的事情 90% 都是请求缓存库已有的能力键值缓存、并发去重、stale-while-revalidate、失败重试。既然工具原生支持我为省一个依赖而自己维护一套属于典型的吃力不讨好。但有些场景确实更适合手动管理比如数据极简、请求一次就不再变化、对加载时序有严格控制的表单页。这时候我不引入缓存库直接 useState useEffect 就够了。我的判断标准很简单页面内是否存在“多接口共享同一份响应数据并在不同时间失效”的情况有就上专用方案没有就直接手写。7. 调试状态时我踩过的坑和排查方法7.1 异步更新导致的“取不到新值”这是所有人第一次写 React 都会遇到的困惑刚 setState马上读 state读到的还是旧值。const [count, setCount] useState(0); function handleClick() { setCount(count 1); console.log(count); // 还是 0而不是 1 }原理就是前面说的setState 不会同步修改当前渲染里的 count 变量它只是排了一个更新任务。若非要立刻拿到新值不应该读取 count而是直接用函数式更新或者把逻辑放到 useEffect 里依赖 count。老实说这种写法大部分场景是设计有问题因为你要的不是新值本身而是“下一次渲染时基于新值做的事”。7.2 闭包陷阱状态为什么不更新闭包问题在事件回调、setTimeout、setInterval 里最常见。比如useEffect(() { const timer setInterval(() { setCount(count 1); }, 1000); return () clearInterval(timer); }, []);这个代码的 timer 是在首次渲染时创建的闭包捕获的是首轮 count 值 0。定时器每次触发setCount(count 1)用的都是旧值 0于是 count 永远停在 1。解决办法就是把 setCount 改成函数式更新setCount(prev prev 1)或者把 count 加入依赖数组让 effect 随着 count 变化重新创建定时器。前者的效率更高也更符合“用函数式更新处理基于旧值的计算”这条原则。7.3 Stale State组件拿到过期状态Stale State 的问题通常出现在异步请求里。比如用户进入详情页请求 A 发出后又快速切换到另一个详情页请求 B 发出。请求 A 的响应晚于请求 B 返回时页面就会显示旧数据。这个问题用 7.1 的 ignore 模式可以解决也可以在请求时带一个 key响应返回时判断 key 是否匹配当前组件的 key不匹配就丢弃。7.4 setState 放在 render 循环里有一次排查一个“页面疯狂闪屏”的问题发现有人在组件体内直接调用了 setState。React 每次渲染时都会重新执行组件函数函数体内直接 setState 就会触发新的渲染新渲染又触发 setState形成无限循环。React 会检测到这种情况并直接抛出错误。常见的错误写法是function App() { const [user, setUser] useState(null); if (!user) { setUser(getDefaultUser()); // 错误 } return div{user.name}/div; }正确的做法要么把 setUser 放到事件回调或 useEffect 里要么用 useMemo 或普通变量计算派生值。记住原则渲染阶段只能读取 state不能修改 state所有副作用应该放到事件回调或 effect 里。7.5 滥用全局状态引发的调试噩梦全局状态库调试起来之所以痛苦是因为数据流动不透明。你点一个按钮界面变了但可能经过了多个 action、多个 reducer、多个异步 sagas/effects。当问题出现时光靠肉眼很难定位是谁、在哪、在什么时机修改了状态。Redux 的插件Redux DevTools能帮你看到 action 和前后 state 差异但前提是大家都规范地通过 action 修改状态。Zustand 的调试则更依赖 console 里手动监听适合小团队。我的建议是局部状态解决得了的问题不要全局化全局状态里能拆小的不要合并成一个大对象通过状态库的插件把每次修改的来源记录下来。能定位到源头调试就成功了一大半。另一个小经验遇到“诡异但无法稳定复现”的状态问题我第一件事是打开 React DevTools 的 “Highlight updates when components render” 功能看重的组件往往很快就会发现是哪一层状态被无谓提升导致的重渲染蔓延。写到这里差不多把 React 里 state 的核心逻辑过了一遍。最后分享几条我在实际项目中坚持的习惯能放到事件里的逻辑就不要放 effect能通过派生计算得到的值就不要重复存一份 state能把状态往下沉就别往上提能明确“这个状态属于哪个组件”就不要急着引入全局方案。React 状态管理没有银弹但你只要把“状态位置、更新方式、传播范围”这三件事想清楚了绝大多数问题都能在设计阶段就避免而不是等到线上报 bug 再去修。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑