资讯详情

资讯详情

React 19 新特性:告别 useEffect 的工程实践指南

最近我把手头几个项目从 React 18 陆续升级到了 React 19最大的感受不是某个新 API 多好用而是很多以前必须靠useEffect才能搞定的活儿现在居然可以不写了。作为一个从 Hooks 发布就开始用、被各种副作用调来调去折磨过无数次的开发者这个变化对我冲击挺大的。今天这篇就想聊聊 React 19 新特性里那些真正能让你少写useEffect的东西包括use()、Actions 表单全家桶、还有那套争议很大的 React Compiler。我会尽量把每个特性的原理、适用场景、代码对比都铺开讲透也顺便把我升级过程中踩过的坑列出来给想迁移的朋友做个参考。1. React 19 的整体设计为什么突然开始聊告别 useEffect1.1 先说清楚React 19 到底改了哪些底层思路React 团队这几年的核心口号一直是 UI f(state)但说实话在 19 之前这个f里藏着太多需要开发者自己处理的东西。最典型的就是数据获取你要先useState开几个变量存 loading、data、error然后useEffect里发请求还得处理竞态、清理、依赖数组。这其实等于把异步流程管理完全抛给了开发者而 React 本身只是个被动渲染器。React 19 的思路转变很直接把从外部拿数据提交表单处理异步状态这些高频场景变成框架原生支持的声明式能力。核心就三条线。第一条是use()让你在渲染过程中直接消费 Promise 和 Context配合 Suspense 把加载状态交给框架。第二条是 Actions把异步过渡、表单提交、乐观更新、pending 状态这些以前散落在各个 Hook 和useEffect里的逻辑收拢成一套统一的上层模式。第三条是 React Compiler在编译期自动做记忆化把useMemo、useCallback甚至一部分useEffect的职责直接接管。这三条线合在一起你会看到大量原来因为没法声明式表达而不得不引来的副作用消失了。所以告别 useEffect并不是说 React 19 把useEffect删了而是说它主要的使用场景正在急剧收窄以后useEffect基本只剩两个用途——同步外部系统比如接入非 React 的第三方库和在渲染朝外同步一次性的逻辑。其余那些因为 Hooks 能力不足而被塞进效应里的工作都有更干净的选择。1.2 为什么以前 useEffect 会沦为万能工具箱要理解 React 19 为什么要费这么大劲革掉一批useEffect得先回到过去看看它是怎么被滥用的。你随便打开一个维护了一年以上的中大型项目useEffect最常见的几种用法基本逃不出这些监听某个 state 变化然后去发请求、根据 props 变化去重置内部状态、订阅外部 store 或事件总线、在状态变化后手动触发另一段逻辑比如登录成功后跳转、甚至有人用它做简单的防抖和节流。这些写法有个共同问题它们把某某状态变化了我要做点什么这套命令式逻辑硬生生塞进了 React 的声明式模型里。一旦页面里塞了几十个相互依赖的useEffect数据流就变得极其隐蔽——你不知道哪次 setState 会触发哪个效应也不知道效应的执行顺序会不会造成用户看见一闪而过的中间态。我在某公司维护过一个老后台系统一个列表页上叠了七八个useEffect去处理筛选条件、分页、URL 参数同步最后排查一个切换筛选条件后页面闪一下的 Bug花了两天半。React 19 的本质思路就是把这类状态联动重新拉回到声明式赛道里。use()解决数据流的声明式表达Actions 解决表单和异步流程的声明式表达编译器解决渲染性能的自动化。这是从根上压缩useEffect的适用范围而不是打补丁。1.3 三个新能力是如何配合缩小 useEffect 的版图的我画个简单的对应关系如果你和我一样是从 18 升上来的这个表应该能帮你快速建立全局观旧时代惯例以前靠什么实现React 19 的新方案进入页面拉数据useEffectuseState loadinguse(Promise) Suspense表单提交后管理 pending/成功/失败useStateuseEffect联动form actionuseActionState表单子组件读取提交状态状态提升 层层透传 propsuseFormStatus交互操作后立即反馈再发请求useStateuseEffect请求 手动回滚useOptimistic手动记忆化性能优化useMemo/useCallback/memoReact Compiler 自动完成读取 Context 做一些联动useContextuseEffect监听use(Context)配合 Suspense 过渡这张表不是严格的替换关系但基本能看出趋势React 19 把 UI 中的数据依赖异步状态依赖都显式化、框架化了开发者不再需要从零手搓。2. use() 全解析渲染期间直接消费异步数据2.1 use() 是什么以及它和 Hooks 规则的分水岭use()是 React 19 新增的一个函数。它的签名非常简单const value use(promiseOrContext);它有两个能力接收一个 Promise 时React 会在该 Promise pending 期间抛出一个信号由最近的 Suspense 边界捕获并渲染 fallbackPromise resolve 后组件会带着解析出的值重新渲染。它也能接收一个 Context 对象作用和useContext一样不过比useContext更自由。关键的分水岭在这里use()不是一个 Hook它不需要遵守只能在顶层调用的规则。它可以在循环、条件语句里调用。这个特性直接解决了一个老痛点——想根据某个条件决定要不要读取 Context 时以前只能在顶层先const ctx useContext(Ctx)再在条件分支里决定是否使用现在可以直接在分支里use(Ctx)。下面这个例子很直观function NavigationItem({ item }) { const auth use(AuthContext); // 可以在条件前调用 if (item.requiresAuth !auth.isAuthenticated) { return LoginRequired /; } return Link to{item.path}{item.label}/Link; }如果换成useContext这个写法虽然也差不多但如果你是在item.requiresAuth为 false 时不想进入这个 Channel 呢use()可以直接把调用放进条件里function MaybeProfileCard({ show }) { if (!show) return null; const user use(UserContext); return ProfileCard user{user} /; }这样的灵活性是useContext给不了的。2.2 用老办法 vs 用 use()一个数据加载组件的完整对比为了让你更直观地感受到告别 useEffect的分量我拿一个最常见的场景对比进入用户主页时加载用户资料和最近订单。老写法React 18 及以前function UserDashboard({ userId }) { const [user, setUser] useState(null); const [orders, setOrders] useState([]); const [loading, setLoading] useState(true); const [error, setError] useState(null); useEffect(() { let ignore false; setLoading(true); setError(null); Promise.all([fetchUser(userId), fetchOrders(userId)]) .then(([u, o]) { if (!ignore) { setUser(u); setOrders(o); setLoading(false); } }) .catch((e) { if (!ignore) { setError(e); setLoading(false); } }); return () { ignore true; }; }, [userId]); if (loading) return Spinner /; if (error) return ErrorView message{error.message} /; return ( div UserProfile user{user} / OrderList orders{orders} / /div ); }这算是不错的写法了至少处理了ignore防止竞态。但依然有几个隐性痛点依赖数组[userId]漏写一个就完蛋loading和error的 set 状态顺序要小心翼翼如果子组件也要拿同一个user还得继续 props 往下传。新写法React 19function UserDashboard({ userId }) { const user use(fetchUser(userId)); const orders use(fetchOrders(userId)); return ( div UserProfile user{user} / OrderList orders{orders} / /div ); }然后在外层包上 Suspense 和错误边界Suspense fallback{Spinner /} ErrorBoundary fallback{ErrorView /} UserDashboard userId{userId} / /ErrorBoundary /Suspense从 50 行降到 10 行而且语义极其清楚use()说这里需要数据没有就等有了就渲染。useEffect整个消失了loading 和 error 也不再是组件里的状态而是由 Suspense 和 ErrorBoundary 统一接管。2.3 使用 use() 时的几个关键限制和踩坑点use()虽然好用但它不是万能药。我总结几个必须注意的点都是自己实际敲出来的第一Promise 必须是稳定且可缓存的。如果每次渲染都传一个新 Promise父组件可能陷入无限循环重渲染。实践中要么把 Promise 放进 state 或通过缓存库例如类似 TanStack Query 的机制构造要么在父组件用useMemo保证同一个 userId 对应同一个 Promise 实例。第二use()自己不能捕获错误。你没法直接在use()外面写 try/catch 拿到 reject 的错误。错误必须由上级 ErrorBoundary 捕获。这也是为什么我总是提醒团队用了use()ErrorBoundary 就不再是可选项而是必需品。第三它目前不能用在if之前有同步渲染依赖的场景里形成渐进加载。是的虽然use()可以条件调用但它一旦遇到 pending 的 Promise会直接中断当前组件的渲染让整个最近的 Suspense 出 fallback而不是等这个组件局部加载完成。换句话说如果你把用户信息和订单信息放在两个use()里两个都会等全部加载完才一起展示。想拆开就往下拆子组件、包多个 Suspense。第四在服务端组件里用use()是很自然的因为服务端没有加载中的概念可以直接等 Promise resolved 再输出 HTML。但在客户端组件里使用必须保证外层有 Suspense否则 React 会警告。我对use()的评价是它解决的是渲染过程本身需要异步数据的问题。它是声明式数据流的基础设施配合 Suspense效果远好于自己手搓 loading 状态。但它不负责缓存策略不负责错误策略这些还是需要你自己的数据层配合。3. Actions 与表单把异步流程收拢进框架3.1 Actions 是什么以及它连接 useState 与 useEffect 的断裂带如果说use()是 React 19 数据获取的前台那 Actions 就是后台调度中枢。Actions 本质上是一个异步过渡函数它把提交操作放进一个特殊的调度流程里React 会跟踪它的 pending 状态、处理乐观更新、自动管理中断和回滚。这个设计直接解决了一个老问题以前我们做表单提交必须自己管理提交中成功失败的状态机并且经常依赖useEffect在状态切换后做额外动作比如清空表单、跳转、弹 toast。一旦项目复杂起来这个状态机就会散落在多个组件和多个useEffect里排查问题极其痛苦。React 19 把表单提交提升为一等公民最基础的用法是这样async function submitAction(formData) { // 在这里做服务端调用或数据处理 await saveToServer(formData); } function ContactForm() { return ( form action{submitAction} input nameemail typeemail required / button typesubmit提交/button /form ); }你没看错form原生属性action现在可以直接接收一个函数。React 会在该函数执行期间自动追踪状态子组件也能通过useFormStatus读取 pending。整个请求生命周期不再依赖任何useState和useEffect。3.2 从 useState useEffect 到 useActionState一个完整表单改造案例来一个稍微复杂的例子这是我实际改造过的一个新建工单表单。先看老的实现思路function CreateTicketForm() { const [title, setTitle] useState(); const [desc, setDesc] useState(); const [status, setStatus] useState(idle); // idle | pending | success | error const [error, setError] useState(null); const handleSubmit async (e) { e.preventDefault(); setStatus(pending); setError(null); try { await createTicket({ title, desc }); setStatus(success); } catch (err) { setStatus(error); setError(err); } }; useEffect(() { if (status success) { setTitle(); setDesc(); } }, [status]); return ( form onSubmit{handleSubmit} input value{title} onChange{(e) setTitle(e.target.value)} / textarea value{desc} onChange{(e) setDesc(e.target.value)} / {status error p classNameerror{error.message}/p} button disabled{status pending}提交/button /form ); }问题一眼可见表单的受控值、提交状态、成功后的重置逻辑全都互相纠缠还靠一个useEffect去做状态变化后的联动。用 React 19 的useActionState重写async function createTicketAction(prevState, formData) { const title formData.get(title); const desc formData.get(desc); try { await createTicket({ title, desc }); return { status: success, message: 工单已创建 }; } catch (err) { return { status: error, message: err.message }; } } function CreateTicketForm() { const [state, formAction, isPending] useActionState(createTicketAction, { status: idle, message: , }); return ( form action{formAction} input nametitle required / textarea namedesc required / {state.message p className{state.status error ? error : success}{state.message}/p} button disabled{isPending} {isPending ? 提交中... : 提交} /button /form ); }useActionState返回三个东西最新的 state、被包装过的 action、pending 标志。重写后代码量少了三分之一而且所有的状态流转都在一个函数体内完成prevState帮忙替代了那堆 setState 联动表单域用了非受控模式不再需要两个useState去维护 value。3.3 useFormStatus 与 useOptimistic最后两块拼图表单场景里还有两个高频需求子组件想知道表单整体提交状态以及提交后希望界面先乐观展示。以前子组件要获取父表单状态只能通过 context 或 props 层层传递而且提交状态本身还得父组件自己维护。现在可以直接在子组件里用useFormStatusfunction SubmitButton() { const { pending } useFormStatus(); return ( button typesubmit disabled{pending} {pending ? 提交中... : 提交} /button ); }这个 Hook 最爽的地方是它不需要接收任何参数自动从最近的父form读取状态。哪怕你的按钮在组件树最深层也能无脑拿到 pending。你再也不用为按钮要禁用去手动提升一堆状态了。useOptimistic则是乐观更新的声明式方案。比方说用户提交一条评论你希望界面上立刻出现这条评论不需要等服务端确认等真正返回成功后再替换为正式版本失败时自动回滚。function CommentList({ comments }) { const [optimisticComments, addOptimisticComment] useOptimistic( comments, (state, newComment) [...state, { ...newComment, isPending: true }] ); async function addCommentAction(formData) { const newComment { id: crypto.randomUUID(), text: formData.get(text), }; addOptimisticComment(newComment); // 先乐观更新 await submitComment(newComment); // 真正发请求 } return ( ul {optimisticComments.map((c) ( li key{c.id} className{c.isPending ? pending : } {c.text} /li ))} /ul form action{addCommentAction} input nametext required / button typesubmit发送/button /form / ); }这里useOptimistic接收一个基础数据comments来自 props 或服务端再传一个 transform 函数。调用addOptimisticComment后会立即更新 UI 并带上临时标记等 action 完成且基础数据变化时React 会丢弃乐观层并渲染真实数据。如果 action 失败基础数据没变乐观层也会被自动回滚。整个过程里我没有写过任何一个 setState也没有用过 useEffect 去等请求完成再改 UI。3.4 Actions 消除的是状态联动这个副作用温床我认为 Actions 全家桶最大的贡献是把一批原本需要手动状态机 效应联动的场景改造成了函数 返回值 表单机制的纯数据流。以前你写的useEffect里有很大一部分其实是在干当这个状态变化时去触发那个状态更新的活这类工作是最难阅读、最容易出 Bug 的——因为 React 的组件渲染是纯函数式而useEffect是在渲染提交后额外插入的命令式副作用。现在有了useActionState状态变更被封装在 action 函数的返回路径里有了useOptimisticUI 即时反馈也被纳入框架管理。站在业务代码的角度看异步流程从多条线牵动多个状态变成了一个 action 函数的收与支数据的局部性一下子强了很多。4. React Compiler算法替代手动的 useMemo、useCallback 与部分 useEffect4.1 编译器在构建期帮你干了什么活聊完运行时的新特性必须把 React Compiler 拉出来说因为它代表了 React 19 另一个层面的思路把性能优化从开发者的手动职责改为编译器的自动职责。React 应用最常见的性能问题就是渲染了不该渲染的组件。以前我们靠React.memo包装子组件靠useMemo缓存对象、数组计算结果靠useCallback缓存函数引用就是为了让子组件的浅比较 props 机制尽量发挥作用。但这些手动标记器的使用极其依赖开发者对引用稳定性的理解稍微写错一个依赖数组缓存要么失效要么过期。React Compiler 的核心原理是在构建阶段对组件代码做静态分析识别出哪些变量在渲染过程中没有变化自动生成等价于useMemo/useCallback的记忆化逻辑。它不需要你写任何useMemo或useCallback。官方叫它 automatic memoization。更关键的是编译器对渲染阶段副作用有约束。它默认组件应保持纯函数不要直接修改 props、不要在渲染中读取 ref 来作为渲染依据、不要在有条件分支里写入非纯逻辑。这些约束恰好也是useEffect时代很多 Bug 的根源——你在渲染里读了一个会变的东西可能连自己都没意识到于是某个变量不稳定导致后续的效应联动出现诡异问题。4.2 编译器是如何间接消灭一批 useEffect 的很多人会问编译器是搞记忆化的跟useEffect有什么关系关系很大。我观察到的现象是有一部分useEffect实际是在补记忆化缺失的锅。举个常见例子某个组件里有一个大型对象在 props 变化时被重建你可能会写useEffect(() { const derived heavyCompute(props.data); setDerivedState(derived); }, [props.data]);这个useEffect存在的理由其实是你担心每次渲染都执行heavyCompute太浪费所以想只在 data 变化时跑。但如果你有编译器自动缓存props.data的派生值你完全可以直接在渲染里算const derived heavyCompute(props.data);因为编译器会保证props.data不变时derived引用不变子组件不会因此重复渲染。于是这个useEffect连同它的 setState 联动一起消失了。再比如很多useEffect是在处理订阅某个 store 的变化然后同步到本地 state。编译器本身不直接解决订阅问题但当数据源变成use()或 context 时订阅逻辑会被框架吃掉useEffect的价值进一步下降。归根到底编译器把渲染的性能稳定性自动化之后剩下的useEffect自然只有那些真正和外部世界交互的活儿。4.3 实践中的真实体验与注意事项我在一个中型后台项目上做了编译器实验。启用方式很简单以 Vite 为例npm install babel-plugin-react-compiler然后在vite.config.js里配置import react from vitejs/plugin-react; export default { plugins: [ react({ babel: { plugins: [babel-plugin-react-compiler], }, }), ], };跑起来之后最大的直观感受是代码里大量手写的useMemo和React.memo可以删掉了组件树的重复渲染肉眼可见地减少。但要注意几点第一编译器对打破纯函数约束的代码会直接报错或警告。此前写法不规范的项目第一次编译会冒出一堆报错。比如有人在渲染里直接操作ref.current.style这就是非纯用法。这种代码在编译器下必须挪进useEffect或事件处理。第二useEffect不会被自动删除。如果你写了一个效应依赖某些状态而该状态被记忆化后引用更稳定效应确实可能少触发但效应本身的逻辑还是要你自己负责。千万不要以为开了编译器就能完全不理解响应的机制。第三编译器目前对动态访问对象属性这类模式支持还不完美某些极端情况下仍会关掉记忆化保护。所以上线前记得用性能分析工具照一遍别盲目裸奔。我的体会是React Compiler 不是为了省掉你所有的性能决策而是把引用稳定性这种底层杂务接过去让你能更集中精力处理真正与业务相关的状态和副作用。它是告别 useEffect这个叙事里的最后一块拼图。5. 常见问题与小团队迁移实践5.1 升级 React 19 时的几个破坏性变更如果你准备从 18 升到 19或者新项目直接上 19有这么几个破坏性变更需要先关注。第一以前 React 对title、meta、link这些标签在 React 组件里的位置很挑剔19 开始允许组件在任意位置直接渲染原生 head 标签React 会自动提升到head并在卸载时清理。这是个大改进但如果你之前用第三方库管理 head需要小心重复。第二forwardRef成了历史。现在 ref 可以直接作为普通 prop 传给函数组件。以前写const FancyInput forwardRef((props, ref) ...)现在直接function FancyInput({ ref, ...props })就行。第三useEffect在 StrictMode 下的双调用行为在 19 里依然保留但你如果用了新的use()或 Suspense 数据流这类双执行带来的心态冲击会小很多因为效应变少了。第四移除了一些旧 API比如字符串 ref、ReactDOM.render等。这些在 18 里已被弃用19 直接删除。升级步骤其实不复杂npm install reactlatest react-domlatest装完后先把全项目跑一遍重点看控制台里有没有新的警告。我遇到最多的是对第三方库的兼容性问题有些老库内部用了forwardRef或旧生命周期 API虽然 19 还能跑但会有警告建议尽早更换或打补丁。5.2 use() 与错误边界、Suspense 的配合细节use()有一个新手几乎必踩的坑它抛出的错误不会冒泡到最近的 try/catch而是直接传给 ErrorBoundary。如果你写的组件用了use()而没有一个 ErrorBoundary 包住一旦请求失败整个页面会白屏报错。正确的姿势是写一个通用的错误边界组件class AppErrorBoundary extends Component { state { hasError: false, error: null }; static getDerivedStateFromError(error) { return { hasError: true, error }; } render() { if (this.state.hasError) { return ErrorScreen error{this.state.error} /; } return this.props.children; } }然后这样组合使用function App() { return ( AppErrorBoundary Suspense fallback{GlobalLoading /} UserDashboard userId{userId} / /Suspense /AppErrorBoundary ); }Suspense 可以嵌套粒度越细体验越好。比如一个页面里既有用户信息又有订单列表可以把它们分别包成两个 Suspense这样用户信息先到就先渲染订单后到也不阻塞主内容。5.3 我的迁移顺序建议从低频区域渐进换血不要妄想把一个老项目一次性全量迁到 React 19 的新模式。我的做法是分四步走。第一步先升级依赖跑通项目确保没有任何破坏性变更报错。这一步不动业务代码。第二步挑一个表单提交场景改成form actionuseActionState。为什么先选表单因为表单的边界最清晰改动影响面可控而且收益立竿见影。改完你会发现以前那堆onSubmit手写状态机真的可以删掉。第三步选一个纯展示型的数据加载页面改成use() Suspense ErrorBoundary。这个过程中你会更深地理解渲染期间异步数据的模型也会发现以前困扰你的 loading 状态管理其实可以完全交给框架。第四步如果项目体积大、性能敏感再接入 React Compiler。这个阶段建议在独立分支上做跑全量回归配合性能工具对比前后的渲染次数和耗时。5.4 哪些场景真的还应该保留 useEffect讲了一整篇告别 useEffect我必须诚实地告诉你有些场景依然得用它而且用了是合理的。第一和外部非 React 系统同步。你接入一个地图 SDK、一个聊天 WebSocket、一个播放器实例需要在组件挂载时初始化、在 props 变化时调用暴露的方法、在卸载时销毁。这类命令式生命周期交互没有替代品必须useEffect。第二订阅浏览器全局事件。比如监听resize、scroll、键盘事件useEffect配合事件绑定清理是最自然的表达。第三在渲染后执行一次性的 UI 操作比如测量 DOM 元素尺寸、手动聚焦输入框。这些操作依赖 DOM 已经渲染完成严格说属于提交后的副作用恰好是useEffect的设计初衷。我见过很多团队用禁止 useEffect作为 React 19 的某种新纪律这其实是矫枉过正。正确的判断标准不是能不能不用 useEffect而是这个操作是否真的属于副作用而不是可以被声明式表达的业务状态流动。用use()能解决的用 Actions 能解决的用编译器能解决的就不该再用useEffect。剩下的那些真正和外部世界握手的活儿大大方方用没人会笑话你。5.5 顺手记录两个实际踩过的坑最后分享两个我升级时印象最深的排错经历希望能帮你少走弯路。第一个坑和use()的 Promise 稳定性有关。我刚开始把use(fetchUser(userId))直接写进组件没有做任何缓存结果发现只要父组件一重渲染虽然userId没变但fetchUser(userId)会返回一个全新的 Promise然后use()又进入 pending 状态页面闪一次 Spinner。后来我用一个简单 Map 做了请求结果缓存问题立刻消失。这也提醒我use()不是万能缓存它只是数据流通道通道背后的数据稳定性要靠自己保证。第二个坑是useOptimistic在表单 action 里的回滚时机。我在做评论功能时乐观更新后如果请求失败我希望把那条乐观评论移除但一开始它回滚得并不干净界面残留了半条 pending 状态的评论。排查后发现是 Transform 函数里我用的是state.map(...)而 React 依赖的是基础数据引用的对比。后来我把 transform 写成不可变更新、严格基于传入的state和newComment构造新数组问题才解决。这也再次印证了不可变数据在 React 19 新特性里依然是铁律。写在最后的一点个人体会从 React 18 到 19 的升级过程我最大的感受不是新 API 真多而是设计哲学终于连贯了。以前 React 给你一个渲染函数和一个副作用逃逸舱剩下全靠自觉现在它给了你use()、Actions、编译器等于把UI 应该由数据和异步状态驱动这件事落实到语言和框架层面。你可以不用它们但一旦用起来会发现自己手写的状态机和联动效应大半都成了冗余。这大概就是框架进化的方向不是让你写更少代码而是让你写更不容易错的代码。如果你正在规划升级我给的建议很简单别一次性推翻重来从表单开始逐步换血。你会跟我一样发现过去那些不得不迁就 Hooks 能力不足的 useEffect终于可以安心退休了。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →