最近我把手头几个项目从 React 18 陆续升级到了 React 19,最大的感受不是某个新 API 多好用,而是很多以前必须靠useEffect才能搞定的活儿,现在居然可以不写了。作为一个从 Hooks 发布就开始用、被各种副作用调来调去折磨过无数次的开发者,这个变化对我冲击挺大的。今天这篇就想聊聊 React 19 新特性里那些真正能让你少写useEffect的东西,包括use()、Actions 表单全家桶、还有那套争议很大的 React Compiler。我会尽量把每个特性的原理、适用场景、代码对比都铺开讲透,也顺便把我升级过程中踩过的坑列出来,给想迁移的朋友做个参考。
1. React 19 的整体设计:为什么突然开始聊"告别 useEffect"
1.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 的新方案 |
|---|---|---|
| 进入页面拉数据 | useEffect+useState+ loading | use(Promise)+ Suspense |
| 表单提交后管理 pending/成功/失败 | useState+useEffect联动 | <form action>+useActionState |
| 表单子组件读取提交状态 | 状态提升 + 层层透传 props | useFormStatus |
| 交互操作后立即反馈再发请求 | useState+useEffect请求 + 手动回滚 | useOptimistic |
| 手动记忆化性能优化 | useMemo/useCallback/memo | React Compiler 自动完成 |
| 读取 Context 做一些联动 | useContext+useEffect监听 | use(Context)配合 Suspense 过渡 |
这张表不是严格的替换关系,但基本能看出趋势:React 19 把 UI 中的"数据依赖""异步状态依赖"都显式化、框架化了,开发者不再需要从零手搓。
2. use() 全解析:渲染期间直接消费异步数据
2.1 use() 是什么,以及它和 Hooks 规则的分水岭
use()是 React 19 新增的一个函数。它的签名非常简单:
const value = use(promiseOrContext);它有两个能力:接收一个 Promise 时,React 会在该 Promise pending 期间抛出一个"信号",由最近的 Suspense 边界捕获并渲染 fallback;Promise 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 19):
function 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 name="email" type="email" required /> <button type="submit">提交</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 className="error">{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 name="title" required /> <textarea name="desc" 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 层层传递,而且提交状态本身还得父组件自己维护。现在可以直接在子组件里用useFormStatus:
function SubmitButton() { const { pending } = useFormStatus(); return ( <button type="submit" 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 name="text" required /> <button type="submit">发送</button> </form> </> ); }这里useOptimistic接收一个基础数据comments(来自 props 或服务端),再传一个 transform 函数。调用addOptimisticComment后会立即更新 UI 并带上临时标记,等 action 完成且基础数据变化时,React 会丢弃乐观层并渲染真实数据。如果 action 失败,基础数据没变,乐观层也会被自动回滚。整个过程里我没有写过任何一个 setState,也没有用过 useEffect 去"等请求完成再改 UI"。
3.4 Actions 消除的,是"状态联动"这个副作用温床
我认为 Actions 全家桶最大的贡献,是把一批原本需要"手动状态机 + 效应联动"的场景,改造成了"函数 + 返回值 + 表单机制"的纯数据流。以前你写的useEffect里有很大一部分其实是在干"当这个状态变化时,去触发那个状态更新"的活,这类工作是最难阅读、最容易出 Bug 的——因为 React 的组件渲染是纯函数式,而useEffect是在渲染提交后额外插入的命令式副作用。
现在有了useActionState,状态变更被封装在 action 函数的返回路径里;有了useOptimistic,UI 即时反馈也被纳入框架管理。站在业务代码的角度看,异步流程从"多条线牵动多个状态"变成了"一个 action 函数的收与支",数据的局部性一下子强了很多。
4. React Compiler:算法替代手动的 useMemo、useCallback 与部分 useEffect
4.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 react@latest react-dom@latest装完后先把全项目跑一遍,重点看控制台里有没有新的警告。我遇到最多的是对第三方库的兼容性问题,有些老库内部用了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 action>+useActionState。为什么先选表单?因为表单的边界最清晰,改动影响面可控,而且收益立竿见影。改完你会发现以前那堆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,终于可以安心退休了。