最近几个月,React 社区讨论最凶的话题之一,就是 React 19 新特性里反复提到的“告别 useEffect 的时代”。作为从 React 16 一路用到现在的老开发,我第一反应是“又开始造概念了”,但真正把 React 19 的新 API 在项目里跑了一遍之后,我必须承认:这次不是营销话术,而是很多你以前不得不写 useEffect 的场景,确实有了更优雅、更不容易出 bug 的替代方案。
这篇文章,我打算按实际项目的使用顺序来拆解:先说 React 19 到底在“解决什么问题”,再逐个掰开 useOptimistic、Actions、useActionState、use()、ref 作为 props 这些新特性,重点讲清楚“新写法为什么能替代 useEffect”“老写法到底哪里有坑”,最后附上我在迁移过程中踩到的坑和排查经验。通篇采用真实代码对比和实操建议,不想看你直接抄作业也行。
1. React 19 的“告别 useEffect”到底是怎么回事
1.1 React 19 带来了哪些核心变化
很多人一听“React 19”,第一反应是“又一个版本号更新”,觉得无非是性能优化、少量 API 调整。但这一版的改动幅度,比我预想中大得多。它主要有几个大招:Actions(异步动作的统一解决方案)、useOptimistic(乐观更新)、useActionState(表单状态管理)、use()(在渲染中读取 Promise 或 Context)、ref 作为 props 直接传递、ref cleanup 函数,以及配套的 React Compiler。
这些特性单独看各自解决一块问题,但串起来你会发现一个共同点:它们都在替你管理“异步状态切换”这件事。以前这些逻辑分散在 useEffect + useState + 手动 loading 标记里,现在被统一收敛到了框架层。所以“告别 useEffect 的时代”这个说法,本质上是在说:React 的官方范式从“副作用驱动”转向了“状态驱动”。
对前端开发者来说,这等于以前你手动做的很多“脏活”——监听某个状态变化、发起请求、再更新另一个状态、处理竞态、清理副作用——现在有了更声明式的写法。它不是让你的代码变少那么简单,是让状态和 UI 之间的因果链路变得更直接、更好推理。
1.2 别误解:不是废除,是职责重归
这里必须先泼一盆冷水:React 19 并没有删除 useEffect,也不存在“完全不用写 useEffect”的魔法。官方文档里把 useEffect 的定位重新梳理过,强调的是“当你无法用现有特性完成需求时,才需要用到 Effect”这种思路。
从我的实践体会来看,新特性替代掉的是那批“因为缺少更好的工具,所以被迫用 useEffect/useState 拼出来的副作用”。典型例子有三类:
- 乐观更新:以前需要手动维护“临时成功态 + 失败回滚 + 实际成功同步”,现在 useOptimistic 一步到位。
- 表单提交状态:以前要自己搞 pending、error、success 三个 state,再配合 useEffect 监听提交结果,现在 useActionState / useFormStatus 原生支持。
- 异步资源消费:以前用 useEffect 发请求 + setState,导致在水合、竞态、重复请求上反复踩坑,现在 use() 配合 Suspense 从渲染层直接消费。
但还有一类 useEffect 是无论如何都应该保留的:和 React 外部系统同步。比如订阅第三方事件源、操作浏览器原生 API、手动操作 DOM、接入非 React 管理的图表库。这些场景里 useEffect 就是正确工具,因为它就是 React 留下的“逃生舱”。
所以更准确的说法是:React 19 让 useEffect 从“默认选项”变成了“兜底选项”。这个心智转变,比学会几个新 API 重要得多。
2. useOptimistic:把“乐观更新”从 useEffect 手里彻底接走
2.1 老写法回顾:useEffect 是怎么处理乐观更新的
先说个场景:你在做一个点赞功能,用户点击后希望按钮立刻变成“已点赞”,同时后台请求继续发。如果等接口返回再更新 UI,网络慢的时候用户会明显感觉到卡顿。这就是乐观更新的经典场景。
以前我的写法大概是这样的:
const [liked, setLiked] = useState(false); const [pending, setPending] = useState(false); const handleLike = async () => { setPending(true); setLiked(true); // 乐观更新 try { await likeRequest(); } catch (e) { setLiked(false); // 失败回滚 } finally { setPending(false); } }; useEffect(() => { // 如果别的组件也改了 liked,这里要同步 }, [liked]);看起来问题不大,但实际项目里要比这复杂:你要处理接口失败后的错误提示、要防止用户重复点击、要在多个组件共享同一份数据时保证状态一致。最难受的是,如果这个“liked”状态来自全局 store,你还得在 useEffect 里监听 store 变化再回写 UI,一不小心就出现状态不一致。这类逻辑写多了,你会发现 useEffect 的依赖数组里装满了各种状态,而它真正的用途只是“同步两个数据源”。
这里还有一个很隐晦的坑:乐观更新和实际更新的错位。用户点赞后立刻看到成功,但如果另一个操作也修改了同一份数据,useEffect 的触发时机是不可控的,很容易出现“闪一下旧数据”“回滚后又变回新数据”的诡异表现。
2.2 新写法:useOptimistic + Action 的完整流程
React 19 的 useOptimistic 就是为这个场景设计的。它不再要求你自己维护“临时状态”和“真实状态”的同步关系,而是让你声明式地告诉 React:“这个状态是乐观值,一旦触发了某个动作,UI 先用我给出的临时值”。
import { useOptimistic, useTransition } from 'react'; function LikeButton({ liked, onLike }) { const [isPending, startTransition] = useTransition(); const [optimisticLiked, addOptimisticLiked] = useOptimistic( liked, (state, newLiked) => newLiked // 更新函数,返回乐观后的状态 ); const handleLike = () => { // 在 transition 里同步触发乐观更新 startTransition(async () => { addOptimisticLiked(!liked); // 立刻把 UI 切到新状态 await onLike(); // 发请求 }); }; return ( <button onClick={handleLike} disabled={isPending}> {optimisticLiked ? '已点赞' : '点赞'} </button> ); }这里的关键就是addOptimisticLiked。它的作用不是直接修改状态,而是告诉 useOptimistic:“这次更新你先把 UI 临时变成这个值”,底层真实状态liked不会立刻改变。当onLike()这个异步操作最终完成并且 React 完成重新渲染后,useOptimistic 会自动丢弃临时值,UI 切换回真实状态。
这样就省掉了三个东西:手动保存“真实值”和“临时值”的双份状态、每次操作后“对账”的 useEffect、以及失败回滚时手写的补偿逻辑。因为 Action 内部抛异常时,React 会保证 UI 回滚到调用前状态。
2.3 使用 useOptimistic 的注意事项
第一,useOptimistic 必须和某个“触发函数”配合才能真正发挥作用。它本身不发起请求,也不管理请求生命周期。就像遥控器不能自己开电视,它得配合 startTransition 里的异步任务才有意义。
第二,乐观更新的“回滚”是自动的,但前提是异步函数必须抛出异常。如果你在 onLike 里自己 catch 了错误并且吞掉,React 永远不知道请求失败了,UI 就会停在错误的乐观状态上。
第三,不要在 setState 或 useOptimistic 的更新函数里执行有副作用的操作。React 的更新函数可能是重复调用的,不能在里头发请求、改 DOM。
第四,嵌套乐观更新的场景要小心。比如一个列表里每个子项都能点赞,你可以在父组件用一个 useOptimistic 管理整个列表状态,更新函数里根据 id 找到对应项修改。这种情况下更新函数要保持纯函数,不能直接修改原数组,要返回新数组。
3. Actions、useActionState 与表单状态链路重构
3.1 过去的表单加载状态有多痛
表单提交一直是 React 开发里最繁琐的部分之一。以前我做一个“新增文章”的表单,大概需要这么一堆东西:
const [title, setTitle] = useState(''); const [content, setContent] = useState(''); const [error, setError] = useState<null | string>(null); const [isPending, setIsPending] = useState(false); const [result, setResult] = useState<null | Article>(null); const handleSubmit = async (e: FormEvent) => { e.preventDefault(); setIsPending(true); setError(null); try { const data = await createArticle({ title, content }); setResult(data); setTitle(''); setContent(''); } catch (err) { setError(err.message); } finally { setIsPending(false); } };这个写法本身不算错,但你在真实项目里还需要考虑:提交成功后要不要重置表单?失败后错误提示要显示在哪里?如果用户在 pending 状态下切换页面,组件卸载后再 setState 会不会警告?这些逻辑一旦多起来,就不可避免地要用 useEffect 去“监听某个状态变化,再做额外处理”。
而且很多团队会把表单状态放到全局 store 里,导致每个字段都要写 action、reducer,代码量翻了三倍,但功能没变多。
3.2 useActionState:把状态收进一个 hook
React 19 里新增的useActionState,直接把“提交动作 + 提交状态 + 返回值”打包成了一个 hook:
import { useActionState } from 'react'; async function createArticleAction(prevState, formData) { const title = formData.get('title'); const content = formData.get('content'); try { const article = await createArticle({ title, content }); return { status: 'success', article }; } catch (e) { return { status: 'error', message: e.message }; } } function ArticleForm() { const [state, formAction, isPending] = useActionState(createArticleAction, { status: 'idle', }); return ( <form action={formAction}> <input name="title" /> <textarea name="content" /> <button disabled={isPending}> {isPending ? '提交中...' : '提交'} </button> {state.status === 'error' && <p>{state.message}</p>} {state.status === 'success' && <p>创建成功</p>} </form> ); }注意这里和之前最大的区别:state 是 action 的返回值,不再是组件里手动 setState 出来的。所以你再也不需要 useEffect 去监听“成功状态”然后弹提示、重置表单了。Action 函数本身就是一个完整的流程处理函数,它接收上一个 state 和 formData,返回新 state。
这个思路有点像 reducer,但它是为“异步提交”设计的。特别是isPending,它是 React 内部基于 action 执行状态自动推导出来的,比自己维护的isPending可靠得多,因为后者经常在“组件卸载/请求被取消/并发提交”这些边界场景下出现状态泄露。
3.3 Form Actions 与 useFormStatus 的组合
除了 useActionState,React 19 还允许直接把异步函数传给<form action={fn}>,配合useFormStatus在子组件里读取提交状态:
function SubmitButton() { const { pending } = useFormStatus(); return <button disabled={pending}>{pending ? '保存中...' : '保存'}</button>; } function ProfileForm() { async function updateProfile(formData) { await fetch('/api/profile', { method: 'POST', body: formData }); } return ( <form action={updateProfile}> <input name="nickname" /> <SubmitButton /> </form> ); }useFormStatus只能在<form>的子组件里调用,pending会自动反映这个表单的 action 是否在飞。这个 API 特别适合做“提交按钮拆到独立组件”的场景,因为以前为了让按钮感知 pending,你得把状态提升到两个组件的共同父级,再用 props 层层传下去。
这个设计的巧妙之处在于:提交状态的来源从“手动 setState”变成了“表单 action 的自动状态”。你不需要 useEffect 在提交成功后清空表单,也不需要担心错误状态忘了重置。数据流变成了一根单向管道:用户提交 -> action 执行 -> 返回新 state -> UI 自动更新。
我自己的体会是,只要表单逻辑不涉及“非常规的状态联动”(比如字段 A 的值要影响字段 B 的校验规则),完全可以无脑迁移到 useActionState。代码量的减少是肉眼可见的,而且心智负担低很多——不需要再问“我这个 loading 状态在哪个组件里?要不要放到 context 里?”
4. use():把异步资源消费带进渲染层
4.1 use() 的基本用法
上一个让我觉得“这个 API 应该早点出”的,就是use()。它的作用是在渲染过程中直接读取一个 Promise 或者 Context 的值,而不需要先通过 useState + useEffect 把异步数据搬到状态里。
最简单的用法是这样的:
import { use } from 'react'; function Article({ articlePromise }) { const article = use(articlePromise); return <h1>{article.title}</h1>; }注意,use()不是 hook,它不需要遵守 hooks 规则,可以在条件语句和循环中使用。这一点和所有我们熟悉的 useState、useEffect 都不一样。
在使用 Promise 时,use()需要配合<Suspense>使用。Promise 还没 resolve 时,React 会“卡住”这个组件的渲染,向上寻找最近的 Suspense fallback 显示加载中状态。当 Promise resolve 后,组件恢复渲染,直接拿到数据。
4.2 对比:useEffect 加载数据 vs use() 加载数据
老写法的典型代码:
function Article({ id }) { const [article, setArticle] = useState(null); const [loading, setLoading] = useState(true); useEffect(() => { let cancelled = false; setLoading(true); fetchArticle(id).then((data) => { if (!cancelled) { setArticle(data); setLoading(false); } }); return () => { cancelled = true; // 手动处理竞态 }; }, [id]); if (loading) return <Spinner />; return <h1>{article.title}</h1>; }这段代码里有几个隐藏问题:如果 id 变化太快,cancelled变量可以避免旧请求覆盖新状态,但这属于“自己手写异步取消逻辑”;如果fetchArticle内部不是每次都返回新 Promise,可能还要考虑缓存;如果你在 useEffect 里 setState 的组件已经卸载,虽然cancelled能避免报错,但无谓的请求还是发了。
新写法:
function Article({ articlePromise }) { const article = use(articlePromise); return <h1>{article.title}</h1>; }加载状态完全由外层 Suspense 接管:
function ArticlePage({ id }) { const articlePromise = fetchArticle(id); // 直接创建 Promise,不做 await return ( <Suspense fallback={<Spinner />}> <Article articlePromise={articlePromise} /> </Suspense> ); }最明显的差别是:不再存在“数据还没回来但组件已经渲染出来”的中间态。渲染只会有两个结果:数据在 Suspense 里等待、数据到了直接渲染。你不需要 loading 标记,不需要担心 useEffect 的清理顺序,也不需要自己处理竞态,因为 Suspense 本身保证了最近创建的 Promise 才是当前渲染要消费的。
4.3 use() 的服务端场景与设计边界
use() 的另一个重要场景是服务端组件(RSC)。在服务端组件里,以前要读一个异步数据源通常得把组件改成 async function,直接 await。而 use() 在服务端组件里也可以使用,配合 Suspense 做按需加载。
比如:
async function fetchReviews(productId) { const res = await db.query(...); return res; } function ProductPage({ productId }) { const reviewsPromise = fetchReviews(productId); return ( <div> <ProductInfo productId={productId} /> <Suspense fallback={<div>评论加载中...</div>}> <Reviews reviewsPromise={reviewsPromise} /> </Suspense> </div> ); }这种模式的思路是“在父组件发起异步任务,把 Promise 传给子组件消费”。Promise 可以被多个组件共享,也可以被缓存,链路比 useEffect 发请求清晰得多。
但需要强调:use() 适合的是“渲染依赖的异步数据”,不适合“用户操作触发的异步动作”。点击按钮后发请求、根据结果弹提示,这种场景应该用 Actions 或普通事件处理函数,硬套 use() 会非常别扭。
还有一个容易踩的点:use() 读取 Promise 时,如果 Promise reject 了,React 会把它当作渲染错误抛给最近的 error boundary。所以你要为可能失败的资源准备 error boundary,而不像以前在 useEffect 里可以自己 catch 然后 setError。这个属于思维方式变化,刚迁移的时候容易漏。
5. ref 作为 props、ref cleanup 与 DOM 操作的减法
5.1 forwardRef 终于要退役了
React 19 里一个非常接地气的变化:函数组件可以直接接收 ref 作为 props。以前想从父组件操作一个函数组件内部的 DOM 节点,必须用 forwardRef 包一层,等于给组件额外加一个 ref 槽位。这导致代码里到处都是 forwardRef 包裹、props 穿透、类型标注的噪音。
老写法:
const Input = React.forwardRef<HTMLInputElement, Props>((props, ref) => { return <input ref={ref} {...props} />; });新写法:
function Input({ ref, ...props }: Props & { ref?: React.Ref<HTMLInputElement> }) { return <input ref={ref} {...props} />; }从组件使用者的角度,这两种写法调用方式一样,差别在组件的定义侧。但你别小看这个改变:以前很多组件库为了透传 ref,不得不用 forwardRef 包好几层,导致 React DevTools 里组件树层级非常深。现在直接写 props 就能拿到 ref,组件树更扁平,排查问题也更方便。
5.2 ref cleanup:useEffect 清理逻辑的替代者
这个特性很长一段时间我只在 React 19 RC 的 changelog 里看到,但实际用下来非常实用。React 19 允许ref 回调函数返回一个清理函数,当 ref 被卸载或者 ref 的值发生变化时,会调用这个清理函数:
function Canvas({ width, height }) { return ( <canvas ref={(node) => { if (!node) return; const ctx = node.getContext('2d'); drawSomething(ctx, width, height); return () => { // 清理操作:清理事件监听、取消动画等 cancelAnimationFrame(rafId); }; }} /> ); }以前这类逻辑怎么写?要么用 useEffect 监听 [width, height] 然后操作 ref.current,要么在组件卸载时手动做清理:
const canvasRef = useRef<HTMLCanvasElement>(null); useEffect(() => { const ctx = canvasRef.current?.getContext('2d'); if (!ctx) return; const rafId = drawSomething(ctx, width, height); return () => cancelAnimationFrame(rafId); }, [width, height]);对比可以发现,ref cleanup 把“DOM 节点和外部资源的生命周期绑定”这件事从 useEffect 里拆了出来。只要某个资源只和单个 DOM 节点相关,就应该写在 ref 回调里,而不是 useEffect 里。这减少了 useEffect 的使用场景,也让代码更内聚:DOM 的创建、使用、销毁都在同一个回调里表达。
5.3 哪些 DOM 场景还是得靠 useEffect
虽然 ref cleanup 很香,但有一种场景它替代不了:依赖 React 状态,但操作目标不是单个 ref 的场景。比如你要监听窗口 resize 事件,这个事件和任何 DOM ref 都无关,只和组件生命周期有关,那还是要用 useEffect:
useEffect(() => { const handleResize = () => setWidth(window.innerWidth); window.addEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize); }, []);再比如你要在多个节点上注册事件、要在 props 变化时重置第三方图表实例、要在组件卸载时清理全局事件总线……这些仍然是 useEffect 的主场。记住一个判断标准:数据源是否跟某个 DOM 节点共存亡。如果答案是“是”,用 ref cleanup;如果答案是“否”,用 useEffect。
6. 常见问题与迁移实操建议
6.1 React Compiler 和 useEffect 的关系
聊 React 19 就绕不开 React Compiler。编译器会自动记住组件的状态依赖,帮你避免不必要的重渲染。这直接导致 useMemo、useCallback 在很多场景下不用再手写了,官方甚至说 UseMemo/useCallback 这类 API 未来会逐步退出主流。
但有个很现实的问题:编译器会优化自动记忆,不会帮你把 useEffect 删掉。因为 useEffect 是显式声明的副作用,编译器无法判断“这个副作用是不是必须的”。所以“告别 useEffect”只能靠你自己改写成新特性来实现,编译器不会魔法般地替你完成。
所以实际迁移建议是:先把依赖 useMemo/useCallback 的负担去掉,再一个个审视 useEffect 的场景。哪些能改成 useOptimistic、useActionState、use()、ref cleanup 就改;改不了的,保留 useEffect 也完全没问题,React 团队自己都说了 useEffect 不会消失。
6.2 升级到 React 19 前要做的检查
第一,确认第三方依赖兼容性。特别是 react-router、antd、zustand 这些常用库,它们内部可能使用了被废弃的 API。我升级时遇到的最大坑是某些组件库在 React 19 下会触发forwardRef相关警告,原因是 React 19 对forwardRef的渲染行为做了调整,需要组件库作者适配。
第二,TypeScript 类型定义。React 19 的 @types/react 更新后,useRef必须传参数:
// 老写法,React 19 类型下会报错 const ref = useRef<HTMLDivElement>(null); // 新写法 const ref = useRef<HTMLDivElement>(null!);这类类型层面的 breaking change 很隐蔽,不会立刻红屏,但会在 CI 类型检查时挂掉,最好提前跑一遍 tsc。
第三,React Native 开发者注意,React 19 在 React Native 上的适配比 Web 慢半拍。我当时在 RN 项目里测试新架构时,发现 useOptimistic 等新 hook 在 RN 新架构下基本可用,但个别 API(比如 Form Actions)在 RN 里意义不大,因为没有<form>这个概念。别急着把 RN 项目全面升到 React 19,先拿 Web 项目试验。
6.3 避坑速查表
| 场景 | 老做法 | 新做法 | 常见坑 |
|---|---|---|---|
| 表单提交 loading 状态 | useState + 手动 set | useActionState / useFormStatus | 忘了处理 action 返回值类型 |
| 乐观更新 | useState + useEffect 对账 | useOptimistic | 在更新函数里做副作用 |
| 异步数据加载 | useEffect + setState | use() + Suspense | 没有 error boundary 兜底 |
| 子组件 ref 透传 | forwardRef | 直接传 ref prop | 类型定义没升级 |
| DOM 清理逻辑 | useEffect + return cleanup | ref cleanup 函数 | 误用在全局事件上 |
最后分享一个我实际踩过的坑。刚开始用 useActionState 时,我以为 action 的返回值可以直接在 render 里访问,于是直接在组件顶层解构 state:
const [state, formAction, isPending] = useActionState(submitAction, initialState); console.log(state.someField); // 第一次是 undefined第一次渲染时 state 是 initialState,这没问题。但如果你在 action 里返回了一个结构不同的对象,比如只在成功时才有某个字段,那组件在“提交中”和“提交失败”时访问这个字段就会报错。建议把所有可能的返回值都设计成相同结构,或者在渲染前做空值兜底。这类问题在 useEffect 时代不容易出现,因为你可以用 useEffect 监听状态变化,但新写法下它会在渲染阶段直接炸,调试成本反而更高。
另一个建议是:升级到 React 19 后,先别急着把项目里所有 useEffect 全部改掉。我从实践中得到的经验是,一次只迁移一个页面,优先选择表单密集、交互反馈多的模块,比如评论、点赞、订单提交这类场景。这类页面改造成果最明显,代码量能砍掉一半。而一些数据展示型页面,比如 Dashboard 大屏,useEffect 做数据轮询仍然是最直接的方案,强行改造反而会让代码更绕。
按这个节奏,我在一个中后台项目里把 useEffect 使用量从 187 处降到了 92 处,不是为降而降,而是每删一处都伴随 dependencies 数组的简化、loading 状态变量的减少、组件 return 分支的收敛。这种“代码瘦身”带来的维护收益,比数据上的好看要实在得多。