跳到主要内容

React 19 关键更新

React 19 已经在 2024 年 12 月发布稳定版。这一版加了不少 API,变化主要集中在两处:一是把提交数据、等待结果、乐观更新这一套流程收进 React,二是继续补齐服务端渲染和资源加载能力。

Actions、useref 和 Context 改变的是客户端组件与表单的写法;Server Components、静态预渲染和资源加载,则补充了 React 在全栈框架、SSR 和 SSG 中的能力。

Actions:异步更新有了统一写法

以前提交表单时,通常要自己维护 isPending、错误信息和请求成功后的状态。要做乐观更新,还得再保存一份临时状态,并在请求失败时回滚。代码并不难,但每个提交入口都要重复一遍。

按照 React 的约定,交给异步 Transition 执行的函数称为 Action。Action 不是一种新的函数语法,关键在于它被 startTransition、表单等支持 Action 的 React API 调用,React 因而能接管这次数据更新的完整过程。

Action 带来的不只是一个 pending 状态,而是一套配套行为:

  • 等待状态:请求开始时进入 pending,最后一次状态更新提交后自动结束;
  • 乐观更新:配合 useOptimistic 立即展示预计结果,请求失败时自动恢复原值;
  • 错误处理:未处理的错误可以交给 Error Boundary,同时撤销对应的乐观更新;
  • 表单处理actionformAction 可以直接接收函数,提交成功后自动重置非受控的表单项。

React 会在等待期间保持当前页面可交互,不需要业务代码为每次提交重复拼装整套状态管理。

React 18 已经能用 Suspense 处理异步数据读取,但提交表单、调用 POST 接口这类“写操作”并不适合套进 Suspense。Action 补的正是这一块:把更新数据的异步过程交给 React 管理。

useTransition 从 React 18 到 React 19

useTransition 不是 React 19 新增的。React 18 里的 useTransition 用来降低某次状态更新的优先级,避免一段很重的渲染卡住其他交互:

function Counter() {
const [count, setCount] = useState(0);
const [isPending, startTransition] = useTransition();

function handleClick() {
startTransition(() => {
setCount(count => count + 1);
});
}

return (
<div>
<CountDisplay count={count} />
<button type="button" onClick={handleClick} disabled={isPending}>
加一
</button>
</div>
);
}

CountDisplay 渲染很慢时,这次更新可以在后台进行,React 仍能响应优先级更高的操作。isPending 会一直保持 true,直到这次更新的渲染完成;如果更新触发了组件挂起,则会等到挂起解除。

这里的关键是,React 18 传给 startTransition 的回调必须是同步函数。它跟踪的是状态更新及其渲染,不会替业务代码等待一个异步请求。

React 19 允许 startTransition 接收异步函数。异步函数交给支持 Action 的 React API 后,React 才会把它当作 Action 处理:

function UpdateName({name}) {
const [isPending, startTransition] = useTransition();

function handleSubmit() {
startTransition(async () => {
await updateName(name);
redirect('/profile');
});
}

return (
<button type="button" onClick={handleSubmit} disabled={isPending}>
{isPending ? '保存中…' : '保存'}
</button>
);
}

这一次,isPending 覆盖的不只是重新渲染,还包括 updateName 的等待时间。React 18 的 Transition 解决“这次状态更新别挡住其他操作”,React 19 的 Action 则把请求本身也纳入了这段过渡过程。

Action 中没有处理的错误可以交给 Error Boundary。如果接口失败只需要在当前表单旁显示提示,而不是让整个区域进入错误状态,就在 Action 内部用 try...catch 处理并返回错误信息。

围绕 Action,React 19 提供了几块配套能力:

  • useActionState:执行 Action,同时拿到上一次返回的结果和 pending 状态;
  • <form action={fn}>:表单可以直接接收函数,不必再手写 onSubmit
  • useFormStatus:子组件可以直接读取所在表单的提交状态;
  • useOptimistic:请求还没完成时,先把预计成功后的界面展示出来。

一个常见的修改名称表单可以写成这样:

import {useActionState} from 'react';

function ChangeName() {
const [error, submitAction, isPending] = useActionState(
async (_previousError, formData) => {
const error = await updateName(formData.get('name'));
return error ?? null;
},
null,
);

return (
<form action={submitAction}>
<input name="name" />
<button type="submit" disabled={isPending}>
保存
</button>
{error && <p>{error}</p>}
</form>
);
}

这里的 submitActionuseActionState 包装过的 Action。提交之后,isPending 会自动变化,Action 的返回值会成为新的 error。如果表单提交成功,React 还会自动重置其中非受控的表单项;需要手动重置时,可以调用 requestFormReset

useFormStatus 让按钮自己读取表单状态

设计系统里的提交按钮通常封装得比较深。React 19 不需要为了传一个 pending 层层加 props,按钮可以直接读取父级表单的状态:

import {useFormStatus} from 'react-dom';

function SubmitButton() {
const {pending} = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? '保存中…' : '保存'}
</button>
);
}

useFormStatus 读取的是组件所在的父级 <form>,用起来很像一个专门服务于表单的 Context。

useOptimistic 先给反馈,再等请求

点赞、改名、发送消息这类操作,用户点完以后没必要干等服务器返回。useOptimistic 可以立即算出并展示一个临时结果,请求结束或报错后,React 再回到外部传入的真实值。

function ChangeName({currentName, onUpdateName}) {
const [optimisticName, setOptimisticName] = useOptimistic(currentName);

async function submitAction(formData) {
const newName = formData.get('name');
setOptimisticName(newName);
const savedName = await updateName(newName);
onUpdateName(savedName);
}

return (
<form action={submitAction}>
<p>当前显示:{optimisticName}</p>
<input name="name" />
<button type="submit">修改</button>
</form>
);
}

这几个 API 并不是互相替代的关系。<form> 负责提交入口,useActionState 管 Action 的结果,useFormStatus 给表单内部组件提供状态,useOptimistic 负责等待期间的即时反馈。

use:在渲染期间读取资源

新的 use API 可以在渲染期间读取 Promise。Promise 还没完成时,组件会挂起,由最近的 Suspense 边界显示 fallback;完成后再继续渲染。

import {Suspense, use} from 'react';

function Comments({commentsPromise}) {
const comments = use(commentsPromise);
return comments.map(comment => <p key={comment.id}>{comment.content}</p>);
}

function Page({commentsPromise}) {
return (
<Suspense fallback={<p>评论加载中…</p>}>
<Comments commentsPromise={commentsPromise} />
</Suspense>
);
}

有个限制很重要:不要在组件渲染时临时创建 Promise 再交给 use。Promise 应该来自支持 Suspense 和缓存的框架或数据层,否则每次渲染都可能得到一个新 Promise,React 也会给出警告。

use 还可以读取 Context。它只能在渲染期间调用,但和 Hook 不同,可以放在条件分支里,也可以写在提前返回之后:

function Heading({children}) {
if (children == null) return null;

const theme = use(ThemeContext);
return <h1 style={{color: theme.color}}>{children}</h1>;
}

Server Components 和 Server Actions

React 19 包含了 Canary 渠道里的 React Server Components 功能。Server Components 可以在客户端打包之前执行,执行地点既可以是 CI 的构建环境,也可以是处理请求的 Web 服务器。

这里要分清“功能稳定”和“框架底层接口稳定”是两回事:React 19 里的 Server Components 对应用和组件库来说已经稳定,但打包器、框架用来集成 Server Components 的底层 API 不遵循语义化版本规则,React 19.x 的次版本之间也可能变化。自己做框架或打包器集成时,需要锁定具体 React 版本,或者跟随 Canary 版本。

Server Actions 则让客户端组件调用在服务器上执行的异步函数。函数使用 "use server" 指令后,框架会替它生成引用;客户端调用这个引用时,由 React 发请求到服务器执行并返回结果。

"use server" 表示的是 Server Action,不是 Server Component。Server Component 本身没有对应的指令,这是两个概念最容易混淆的地方。

静态生成可以等待数据

react-dom/static 新增了两个静态生成 API:

  • prerender,面向 Web Streams;
  • prerenderToNodeStream,面向 Node.js Streams。

它们比 renderToString 多做了一件关键的事:等待组件所需的数据加载完,再返回静态 HTML 流。

import {prerender} from 'react-dom/static';

async function handler() {
const {prelude} = await prerender(<App />, {
bootstrapScripts: ['/main.js'],
});

return new Response(prelude, {
headers: {'content-type': 'text/html'},
});
}

这两个 API 适合静态站点生成。它们会等全部内容准备好再返回,不会像现有的 React DOM 服务端渲染 API 那样,在数据陆续到达时分段输出内容。

组件 API 更顺手了

函数组件可以直接接收 ref

React 19 允许把 ref 当作函数组件的普通 prop 读取,新组件不必再套一层 forwardRef

function MyInput({placeholder, ref}) {
return <input placeholder={placeholder} ref={ref} />;
}

这个变化只针对函数组件。class 组件收到的 ref 仍然指向组件实例,不会作为 prop 传入。

回调 ref 现在还可以返回清理函数。组件卸载时,React 会调用它:

<input
ref={element => {
connect(element);
return () => disconnect(element);
}}
/>

如果回调返回了清理函数,卸载时 React 不会再额外用 null 调用这个 ref。TypeScript 也会拒绝 ref 回调意外返回其他值,所以以前这种隐式返回写法需要改掉:

- <div ref={current => (instance = current)} />
+ <div ref={current => { instance = current; }} />

Context 可以直接作为 Provider

Context 不必再写 .Provider,可以直接渲染:

const ThemeContext = createContext('');

function App({children}) {
return <ThemeContext value="dark">{children}</ThemeContext>;
}

useDeferredValue 支持初始值

useDeferredValue 增加了第二个参数 initialValue。首次渲染先返回初始值,然后在后台安排一次渲染,换成真正的 deferred value:

const value = useDeferredValue(query, '');

搜索结果较重时,可以让首屏先按空查询渲染,再处理真实输入。

元数据和静态资源可以写在组件里

React 19 可以识别组件里的 <title><meta><link>,渲染时自动把它们提升到文档的 <head>。简单页面可以直接把标题和元数据写在负责内容的组件旁边,不必再用 Effect 手动操作 DOM。

样式表也有了专门处理。给 <link rel="stylesheet"><style> 声明 precedence 后,React 会负责插入顺序、去重,并确保依赖样式的内容展示前样式已经加载。这个能力同时覆盖客户端并发渲染和服务端流式渲染。

异步脚本同样可以写在真正依赖它的组件里。相同的异步脚本即使被多个组件渲染,也只会加载和执行一次;服务端渲染时,React 会把它放进 <head>,排在阻塞绘制的样式、字体和图片预加载之后。

React DOM 还提供了几组资源加载 API:

import {preconnect, prefetchDNS, preinit, preload} from 'react-dom';

prefetchDNS('https://static.example.com');
preconnect('https://static.example.com');
preload('/font.woff2', {as: 'font'});
preload('/page.css', {as: 'style'});
preinit('/page.js', {as: 'script'});

prefetchDNS 适合可能会访问的域名,preconnect 适合确定会访问、但还不知道具体资源的域名;preload 提前加载资源,preinit 则会提前加载并执行脚本或应用样式。

调试和兼容性改进

React 19 对 hydration 错误的提示更集中。开发环境不再为同一个不一致打印多条重复报错,而是给出一条带差异内容的消息,并列出服务端和客户端渲染不一致的常见原因。

hydration 对第三方脚本和浏览器扩展也更宽容。<head><body> 中的意外标签会被跳过,不会仅因为扩展插入了节点就触发不一致。即使其他问题导致整页重新渲染,第三方脚本或扩展插入的样式表也会保留。

错误处理方面,createRoothydrateRoot 增加了两个选项:

  • onCaughtError:错误被 Error Boundary 捕获时调用;
  • onUncaughtError:错误没有被 Error Boundary 捕获时调用。

原有的 onRecoverableError 仍然负责 React 能自行恢复的错误。React 19 也减少了同一个渲染错误被重复记录的情况。

Custom Elements 的行为也补齐了。服务端渲染时,字符串、数字和 true 会作为 attribute 输出,object、symbol、function 和 false 会被省略;客户端渲染时,如果元素实例上存在同名 property,React 就给 property 赋值,否则写成 attribute。

升级说明

本文只梳理新增功能。React 19 同时包含破坏性变更,具体的兼容性变化和迁移步骤见官方升级指南。

参考:React v19React 19 Upgrade Guideアクションの概念と useTransition