资讯动态

React Hooks 组合实战:useState、useEffect、useContext 协同与自定义 Hook 封装

发布时间:2026/10/21 17:48:17 来源:尧图企业网站定制
1. 先把 React Hooks 3 这件事聊透很多同学看到“React Hooks3”这个标题第一反应是“是不是又出了三个新 Hook”。实际搞过一段时间 React 的人应该都清楚社区里每隔一阵就会冒出来各种新名词有的来自 React 官方有的来自生态工具有的纯粹是某个团队内部封装后的习惯叫法。我最初看到这个标题也以为是某种新版本发布后来仔细梳理才发现把 Hooks 相关的东西按“第三个阶段”来理解更靠谱或者按“三个核心 Hooks 的组合运用”来切入也非常顺。这个内容能解决的问题很直接当 useState、useEffect、useContext 单独用都顺了怎么把它们组合起来写出更健壮的组件逻辑怎么在函数组件里组织数据流和副作用怎么把分散在多个组件里的公共状态逻辑抽出来复用。适合的人群也明确已经会用基础 Hooks、但面对复杂业务场景还是容易绕晕的中级前端开发者。如果你刚接触 React 没多久直接上来啃这一篇会有点吃力建议先把基础 Hook 的文档过一遍再回来继续读。Hooks 这套东西从一开始出现目的就是让函数组件拥有类组件的能力同时避开 class 里 this 指向、生命周期逻辑分散这些问题。但很多人用了一段时间后就会发现一个问题基础 Hook 只是起点真正决定代码质量的是组合的思路。你可以在一个组件里写十来个 useState 和 useEffect跑起来没有任何问题但别人接手的时候大概率会想骂人。这不是 Hook 本身的问题是组织方式的问题。那么接下来就从设计思路开始把 Hooks 组合使用的整套逻辑重新拆一遍。2. 为什么需要“Hooks 3”这种组合视角2.1 从“会用单个 Hook”到“体系化用 Hook”的跨越先看一个非常典型的例子。某个列表页面需求是进入页面后从后端拉取数据、展示加载态、支持下拉刷新、点击条目跳详情、离开页面时清理定时器。如果你只是会 useState 和 useEffect大概率会写出这样一段代码function ListPage() { const [data, setData] useState([]); const [loading, setLoading] useState(true); const [error, setError] useState(null); const [refreshing, setRefreshing] useState(false); useEffect(() { fetchData(); }, []); async function fetchData() { setLoading(true); try { const res await fetch(/api/list); const json await res.json(); setData(json.data); } catch (e) { setError(e.message); } finally { setLoading(false); } } async function refreshData() { setRefreshing(true); try { const res await fetch(/api/list?refresh1); const json await res.json(); setData(json.data); } finally { setRefreshing(false); } } return div{/* 渲染逻辑 */}/div; }这段代码能跑但问题很明显loading、refreshing、error 这几个状态各自独立页面同时存在 loading 和 refreshing 为 true 的可能逻辑上就是错的fetchData 和 refreshData 两段逻辑高度重复定时器清理这种副作用被塞在某个 useEffect 里等组件复杂起来副作用与渲染逻辑全部纠缠在一起。这时候就需要换个视角不是“这个组件里有哪些状态”而是“这个页面有几种核心业务能力”。这个视角的转变就是我理解的“Hooks 3”的核心意义——把 Hooks 当作一种组织业务逻辑的思维工具而不是单纯的状态开关。按这个思路上面的列表页可以被拆成几个清晰的逻辑单元列表数据的拉取、加载态管理、定时器副作用管理。2.2 三个关键 Hook 的协作模式为什么要从“三个 Hook 的组合”切入建立体系因为绝大多数业务场景都能被 useState、useEffect、useContext 这三个核心覆盖useState 是数据的载体负责保持“当前应该显示什么”的状态useEffect 是数据的桥梁负责响应状态变化并同步外部副作用useContext 是数据的通路负责跨层级共享信息避免 props 层层透传。这三者的协作模式可以类比成一家小型公司的流程useState 相当于仓库负责储存公司当前资产的记录useEffect 相当于执行部门每次资产记录变化后需要按需进行采购、清理、调整useContext 相当于跨部门沟通渠道让不同层级的信息可以直接传达而不必每次都由管理层祖先组件层层转发。这套协作模式落到具体代码中常见的方式是这样useState 定义核心状态useEffect 监听状态变化并触发副作用useContext 提供跨组件共享的全局状态。例如一个用户登录态的场景AuthContext 里保存用户信息LoginPage 用 useState 管理表单输入登录成功之后通过 useEffect 将用户信息写入 Context并持久化到 localStorage。2.3 用自定义 Hook 拆分逻辑单元当业务逐渐复杂把所有 useState 和 useEffect 都堆在一个组件里组件体积会膨胀到难以维护。这时候最自然的下一步是把这些逻辑抽成一个自定义 Hook。自定义 Hook 本质上就是普通函数只是约定以 use 开头并且内部可以调用其他 Hook。还是刚才的列表页我会把列表数据的获取逻辑抽成一个 useFetchList 的 Hookfunction useFetchList(url, options {}) { const [state, setState] useState({ data: [], loading: false, error: null, refreshing: false }); useEffect(() { let didCancel false; setState(prev ({ ...prev, loading: true })); fetch(url, options) .then(res res.json()) .then(data { if (!didCancel) { setState(prev ({ ...prev, data: data.data, loading: false })); } }) .catch(e { if (!didCancel) { setState(prev ({ ...prev, error: e.message, loading: false })); } }); return () { didCancel true; }; }, [url]); const refresh useCallback(() { // 重新拉取 }, [url]); return { ...state, refresh }; }这样组件里就不需要再关心 fetch 这些细节只负责消费数据。这种拆分的价值在项目变大之后非常明显一个页面组件从几百行缩到几十行新需求加进来时只需要改自定义 Hook 内部逻辑页面组件的结构保持稳定。2.4 什么时候不要用“三 Hook 组合”过度设计这套组合确实好用但不意味着每个函数组件都必须套用。如果你的组件里只有一两个状态、没有副作用、也没有跨组件共享的需求硬套机制只会增加代码量。比如一个纯展示组件function ProductCard({ product }) { return div{product.name} - {product.price}/div; }这种组件完全不需要 Hook。很多人学了一套模式之后就忍不住所有组件都用自定义 Hook 包一层反而把原来简单的代码搞复杂了。合理的评估标准是这样的当组件里出现两个以上的 useState、一个以上无关联的 useEffect、或者多个组件共享同一份数据时才值得引入这种组合与拆分策略。3. 核心逻辑解析状态、副作用与跨组件通信3.1 状态管理的正确姿势多个状态放一个 useState 还是拆开这是每个 React 开发者都会纠结的问题。我见过很多代码把每个字段单独拆成一个 useState也见过有人把整个表单对象塞进一个 useState 里。两种方式各有适用场景。独立的 useState 适合这样几种情况状态之间没有联动更新频率差异大或者状态值本身就是独立实体。比如一个视频播放器当前播放时间、音量、播放/暂停状态都是互相独立的拆开更清晰。对象式 useState 适合字段之间天然关联、经常整体更新的场景。比如表单的输入值一个 Form 里有 name、email、age 三个字段用户每次输入更新其中一个如果拆成三个独立状态处理函数要写三个最后提交时还得组装一遍如果用一个对象每次 setState 时用展开运算符合并更新即可。不过对象式 useState 有一个非常容易踩的坑直接修改对象属性不会触发渲染。很多人会在回调里写成state.name xxx然后发现页面没有任何变化。React 的比较机制靠的是引用变化不是深度比较只有当你创建了一个新对象比如setState({ ...state, name: xxx })才能触发更新。这个坑的关键点就在这里任何 Hook 使用中都绕不开。再考虑到状态之间的协调我会把业务强相关的字段聚合为一个状态对象业务无关的独立字段拆开。比如列表页的 loading、error、refreshing 都与请求状态强相关聚合为一个 state 对象更合适因为它们变化的时机是同构的。3.2 useEffect 的依赖数组不是约束是数据流契约useEffect 的依赖数组是初学者最容易搞混的地方。它本质上是在描述这个副作用依赖哪些数据当依赖项变化时副作用重新执行依赖项不变则不执行。很多人把依赖数组当成“这个 effect 要用到的变量列表”漏了某个变量之后组件运行起来出现陈旧数据。我习惯把依赖数组理解成“数据流的一个节点”。每当渲染发生React 会比较依赖数组里每一项的新旧值如果有变化就触发 effect 重新执行。这里的核心是你必须把 effect 内部引用的所有外部变量都放进依赖数组否则 React 无法知道 effect 是否需要重新执行。为了这一点我通常会遵守两条规则凡是在 effect 里读取的 props 或 state都必须出现在依赖里如果 effect 内部使用的函数是组件内部声明的优先用 useCallback 包一层确保函数引用稳定然后将其加入依赖。实际操作中当涉及深层嵌套的复杂状态时依赖数组并不能帮你做深度比较。useEffect(() { }, [obj])里面 obj 的任何属性变化都不会触发 effect因为 obj 的引用没有变。这种情况要么拆散依赖项要么用 useMemo 创建派生数据。3.3 useContext跨组件通信的边界感Context 提供了一种直接跨层级传递数据的能力避免 props 层层透传。但 useContext 也有明显边界一旦某个 Context 的值变化所有消费它的组件都会重新渲染。这正是性能问题的源头之一。如果你的 Context 里放了一个数据对象每次父组件更新时如果 new 出来的对象引用变了所有子组件会无条件重新渲染。为了控制这种影响常见的做法是把 Context 按职责拆成若干独立的小 Context。比如 user 信息一个 Context主题设置一个 Context权限数据一个 Context。这样更新 user 时不会把主题相关的所有组件也拖下水。另外可以将 Context 的 value 用 useMemo 包裹避免无意义的引用变化。3.4 自定义 Hook 的状态隔离机制很多人写自定义 Hook 时最大的疑问是多个组件都用同一个 Hook它们的状态会不会互相串答案是不会。每个组件在调用 Hook 时Hook 内部 useState 创建的都是独立的 state由该组件实例单独持有。这有点像是每个组件从工厂领取了一套独立工具工具型号相同但归各自所有。这个特性带来了一个自然的分层能力当你把某些逻辑抽成自定义 Hook 后组件之间天然隔离互不污染。比如两个列表页都用了 useFetchList它们的 data、loading 各管各的不会有任何通信。真正需要共享的部分再通过 Context 或者状态管理库显式地共享这样的设计心智负担最小。4. 实操过程从组件代码到完整页面一步步怎么搭4.1 第一步先定数据结构再动手写 Hook很多人的习惯是一上来就写组件 JSX写着写着发现需要状态了才补 useState。这种反向流程容易让组件代码长成一棵没有章法的结构。我建议的顺序是反的先想清楚页面需要哪些数据数据的形态是什么再定义 Hook 的接口。假设要做一个后台管理系统里的用户列表页面。需要的数据大致如下用户列表 users当前加载状态 loading加载失败时的错误 error当前页码 page每页条数 pageSize这就是一个非常标准的请求状态集合。按照前面说的组合思路可以把“需要 loading、error 等并列状态”作为一个整体管理也可以用单一 state 对象管理。我较为推荐用单一对象原因也很实际这组状态的变更总是同步发生的。发起请求时 loading 变 true、error 清空请求结束时 loading 变 false、data 填充。把它们放进同一个 useState 之后状态变更逻辑就收敛在 Hook 内部返回给组件的是一个不可分割的数据快照。4.2 第二步自定义 Hook 里如何组织 useEffect以刚才的用户列表为例我们写一个 useUserList它接收请求参数并返回列表状态和操作函数function useUserList(params) { const [state, setState] useState({ users: [], loading: false, error: null, page: 1, pageSize: 10 }); useEffect(() { let cancelled false; async function loadUsers() { setState(prev ({ ...prev, loading: true, error: null })); try { const query new URLSearchParams({ page: String(state.page), pageSize: String(state.pageSize) }).toString(); const res await fetch(/api/users?${query}); const json await res.json(); if (!cancelled) { setState(prev ({ ...prev, users: json.data, loading: false })); } } catch (e) { if (!cancelled) { setState(prev ({ ...prev, error: e.message, loading: false })); } } } loadUsers(); return () { cancelled true; }; }, [state.page, state.pageSize]); return { ...state }; }这段代码里最关键的不是 fetch 本身而是cancelled标记。它在组件卸载时被置为 true这样异步请求即使晚到了也不会对已卸载组件调用 setState避免了 React 报“在卸载组件上更新状态”的警告。虽然 React 18 之后这类警告没那么常见了但清理副作用依然是一个好习惯。同时依赖数组是[state.page, state.pageSize]这意味着页码变化时自动重新拉取。这种把“依赖驱动”的思维贯穿到代码里的方式是 Hook 组合写法的精髓。你不需要在点击事件里手动调用 fetch只需要改 page请求就被 useEffect 自动触发。4.3 第三步把翻页、搜索这类操作接到组件上有了 useUserList组件内部逻辑就非常干净了function UserListPage() { const { users, loading, error, page, pageSize } useUserList(); const [keyword, setKeyword] useState(); const handlePageChange (nextPage) { // 这里触发的状态更新会通过 useEffect 自动重新拉数据 }; const handleSearch () { // 把 keyword 传给 useUserList变成请求参数 }; return ( div input value{keyword} onChange{(e) setKeyword(e.target.value)} / button onClick{handleSearch}搜索/button {loading div加载中.../div} {error div加载失败{error}/div} ul {users.map(user ( li key{user.id}{user.name}/li ))} /ul button onClick{() handlePageChange(page - 1)}上一页/button button onClick{() handlePageChange(page 1)}下一页/button /div ); }这种设计让组件只做两件事把用户输入事件传递给 Hook把 Hook 返回的数据渲染出来。业务逻辑全都封装在 Hook 里组件要测试或者复用就特别简单。将来如果要把这个列表页改成弹窗里的选择器只需要复用 useUserList 即可组件层整个替换都不会影响数据逻辑。4.4 第四步额外处理的分页参数修改问题上面的代码有个细节要留意page 和 pageSize 放在 useState 内部了所以组件内部 handlePageChange 无法直接修改。如果想要外部控制分页需要把 useUserList 接收的 params 提升为参数function useUserList({ page, pageSize }) { const [state, setState] useState({ users: [], loading: false, error: null }); useEffect(() { let cancelled false; // 请求逻辑 return () { cancelled true; }; }, [page, pageSize]); return { ...state }; }这两种写法分别被称为“内部状态”和“受控状态”。内部状态写法简单、适合自包含的场景受控状态写法更灵活适合需要外部参与状态决策的场景。实际项目里切换很频繁这取决于页面是否需要上层控制列表行为。比如 tab 切换时多个列表共享同一个分页状态就适合用受控写法。4.5 第五步跨组件共享筛选条件用 Context 衔接假设系统里有多个页面都要展示当前的筛选条件并且筛选条件变化后各页面联动更新。这种情况下把筛选条件放在 Context 里比放在各自组件的 state 里更合理。建一个 FilterContextconst FilterContext createContext(null); function FilterProvider({ children }) { const [filters, setFilters] useState({ keyword: , status: all }); const updateFilters useCallback((nextFilters) { setFilters(prev ({ ...prev, ...nextFilters })); }, []); const value useMemo(() ({ filters, updateFilters }), [filters, updateFilters]); return FilterContext.Provider value{value}{children}/FilterContext.Provider; } function useFilters() { const context useContext(FilterContext); if (!context) { throw new Error(useFilters 必须在 FilterProvider 内使用); } return context; }这里有个容易被忽略的细节value 里放了一个updateFilters函数如果不包useCallback每次 Provider 重新渲染时 value 都会变化所有消费 FilterContext 的子组件都会跟着重渲染。这个问题的根源是 Context 的 value 比较逻辑是引用比较不是值比较。所以useMemo在这里并不是表演而是实打实的性能保障。业务组件里调用 useFilters 获取筛选条件再传给 useUserList数据流就串起来了function UserListPage() { const { filters } useFilters(); const { users, loading, error, page } useUserList({ page: 1, keyword: filters.keyword, status: filters.status }); // ... }这样用户在某一个页面改了筛选条件其他订阅了 FilterContext 的页面会自动感知并更新数据。这种“Context 负责共享Hook 负责状态推导”的分工模式恰恰是 React 函数组件时代最常用的组合思路。4.6 一个完整页面的编排示例把上面这些组合起来模拟一个完整的用户管理页面骨架function UserManagePage() { const { filters } useFilters(); const { users, loading, error, page } useUserList({ page: 1, keyword: filters.keyword, status: filters.status }); const [selectedUserId, setSelectedUserId] useState(null); useEffect(() { if (selectedUserId) { document.title 正在查看用户 ${selectedUserId}; } else { document.title 用户管理; } }, [selectedUserId]); return ( div SearchBar / UserTable users{users} loading{loading} error{error} / Pagination page{page} / {selectedUserId UserDetailModal id{selectedUserId} /} /div ); }这个页面组件里只剩两类状态来自 Context 的筛选条件、一个本地选中的用户 ID。其他业务细节全被 Hook 收走。这样写的组件拿到代码评审会上任何人一眼就能看出数据从哪里来、到哪里去维护成本直线下降。5. 实操中的常见报错与排查手册5.1 依赖数组漏项导致的“数据总是旧的”这是 Hooks 使用中最高频的问题之一。表现是effect 里 reads 了某个 props但依赖数组没写props 更新后 effect 不执行界面还是显示旧数据。排查方法很简单把 effect 里用到的所有变量列出来挨个核对依赖数组。如果你用的是现代编辑器和 ESLint 的 react-hooks 插件它通常能自动提示缺失依赖。但依赖插件不等于依赖正确有时插件会建议把整个外部函数加入依赖而那个函数本身没有 useCallback 包裹引用了及时更新的状态导致 effect 频繁触发。这种情况要自己分析是加依赖还是稳定函数。5.2 无限循环setState 在 effect 里触发典型的错误写法useEffect(() { setState(prev ({ ...prev, users: getData() })); }, [state.page]);如果 getData() 每次返回新数组那么 state 变化后 effect 再次执行再次 setState无限循环。解决方式通常是让依赖更新与效果触发保持单向。setState 本身不应该成为 effect 执行的直接依赖除非这是有意为之的解耦逻辑。5.3 组件卸载后警告前面提到的 cancelled 标记解决的就是这个问题。在 useEffect 的清理函数里给一个 flag 标记异步操作的取消状态。另外如果你的请求库已经支持 AbortController直接用也可以。5.4 useContext 使用时容易遇到的三个问题第一个是忘记包裹 Provider导致 useContext 返回 null 或抛错。我的习惯是在自定义 Hook 里做显式判断并抛出明确错误信息而不是让调用方在空值里挣扎。第二个是 value 引用频繁变化导致大范围重渲染解决方案就是前面提到的 useMemo 包 value。第三个是过度使用 Context 导致调试困难。当你打开 React DevTools 发现组件树被一层层 Provider 包裹得密不透风就该反思哪些状态可以挪到其他层级或合成到单一 Provider 里。实际项目中我倾向于按领域拆开而不是无脑包一层全部塞进去。5.5 常见问题速查表问题原因解决思路数据更新后界面不刷新setState 直接修改了对象用展开运算符生成新对象effect 多次执行依赖了引用变化的函数或对象用 useCallback 或 useMemo 稳定引用组件卸载后还 setState异步请求没有取消在清理函数里设置 cancelled 标记useContext 返回 undefined组件不在 Provider 内部在 Hook 内抛出显式错误状态更新触发两次请求React 开发模式严格模式 double-invoke用请求标识或 useEffect 依赖控制幂等性6. 从这套组合模式还能扩展到什么地方如果这个“Hooks 3”的组合思路你已经掌握了自定义 Hook 还能继续做不少事。比较常见的是数据请求 Hook 的泛化。可以把 useFetchList 升级成 useRequest接收 URL、请求参数、转换函数、缓存策略。这样整个项目里的所有请求都可以统一走一个逻辑统一处理 loading、取消、错误重试。不同业务页面之间只存在参数差异代码高度复用。再比如表单场景把输入值、校验规则、错误信息、脏标记封装成 useFormState配合 Context 可以做一个全局表单状态管理。这套东西用 Hooks 做起来比传统 class 组件时代轻量太多。更深一步还能把主题切换、权限判断、路由联动都封装成自定义 Hook。权限场景往往会按角色从接口拉一份权限列表封装成 usePermission 之后任何组件里调用一次就能判断是否可操作。这比在组件里到处 useMemo 计算要清晰不少。当然每次引入新抽象都是有代价的。如果一个自定义 Hook 本身的逻辑比它封装的业务还要复杂那封装就失去了意义。我在实际项目里的判断标准始终是代码的可读性有没有提升出现问题时能不能更快定位改动需求时是不是只需要动一处。如果答案都是肯定的这种抽法才是值得的。最终这段实践沉淀下来的结论很简单Hooks 并不是越多越好也不是越封装越高级而是要用它们把数据流梳理清楚把变化收敛到可控范围。React 的函数组件时代代码的核心沉淀路径就是状态、副作用和共享数据的组合设计理解了这条主线之后再多新 Hook 出现你也能很快吸收进自己的体系里。

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

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

免费获取报价 →
↑