React 19 升级最劝退的往往不是新 API,而是升上去之后控制台里那一片报错。useRef() 不传参数过不了类型检查,ref 回调写成箭头函数的隐式返回会被 TypeScript 判错,propTypes 和函数组件的 defaultProps 干脆没了。这些都不是 bug,是 React 团队有意做的取舍。这篇把新特性和破坏性变更放在一起讲,Actions、use、ref 清理函数各自解决什么问题,哪些老写法必须改,升级按什么顺序走最不容易翻车。
在本篇文章中,我们将从浅入深,和大家一起学习以下知识:
- React 19 的安装方式、JSX Transform 要求和升级前的准备工作
- Actions 是什么,它把表单里的哪些样板状态接管掉了
useActionState、useFormStatus、useOptimistic三个新 Hook 的分工- 全新的
useAPI 为什么可以写在条件语句里面 - ref 的两处变化,清理函数和 ref 作为普通 prop
- 文档元数据、样式表 precedence、资源预加载这几个渲染层能力
- 错误处理与 hydration 报错的改进
- Server Components 与 Server Actions 的定位
- 被移除的 API 清单、TypeScript 变更和一份可以照着做的升级步骤
# 一、升级前先把环境理顺
安装本身没什么可说的,官方推荐锁死版本号,避免依赖树里混进不同的 React 副本。
# npm
npm install --save-exact react@^19.0.0 react-dom@^19.0.0
# yarn
yarn add --save-exact react@^19.0.0 react-dom@^19.0.0
用 TypeScript 的项目要同步更新类型定义,否则新增的 API 在编辑器里全是红线。
npm install --save-exact @types/react@^19.0.0 @types/react-dom@^19.0.0
真正会卡住人的是 JSX Transform。React 19 要求使用新的 JSX Transform,如果项目里还是老的,启动时会看到这样一段警告。
Your app (or one of its dependencies) is using an outdated JSX transform.
Update to the modern JSX transform for faster performance.
大多数项目其实不受影响,Vite、Next.js、CRA 这些脚手架早就默认开了新 Transform。会中招的一般是自己维护 babel 配置的老项目,或者某个很久没更新的第三方组件库。这里有个坑要注意,警告文案里提到的「依赖」不是随口一说,有时候是你自己没问题,是 node_modules 里某个包在用旧的 React.createElement 路径。
# 二、Actions 把表单的样板状态接管掉了
先说结论,Actions 不是一个新函数,而是一套约定。凡是传给 startTransition 或者传给 <form action={...}> 的异步函数,React 都把它当成 Action 来处理,自动帮你管 pending、错误和乐观更新的回滚。
看一下老写法长什么样。
// 传统写法,三个状态全靠手动维护
function UpdateName() {
const [name, setName] = useState("");
const [error, setError] = useState(null);
const [isPending, setIsPending] = useState(false);
const handleSubmit = async () => {
setIsPending(true);
const error = await updateName(name);
setIsPending(false);
if (error) {
setError(error);
return;
}
redirect("/path");
};
return (
<div>
<input value={name} onChange={(e) => setName(e.target.value)} />
<button onClick={handleSubmit} disabled={isPending}>
Update
</button>
{error && <p>{error}</p>}
</div>
);
}
isPending 这个状态最烦人的地方在于,它必须在 await 前后各设一次,中间只要多一个提前 return 的分支,就有可能漏掉复位,按钮永远卡在禁用态。这个我踩过。
换成 React 19 的写法,useTransition 直接给你一个 isPending。
// React 19,pending 状态交给 transition
function UpdateName() {
const [name, setName] = useState("");
const [isPending, startTransition] = useTransition();
const handleSubmit = () => {
startTransition(async () => {
const error = await updateName(name);
if (error) {
setError(error);
return;
}
redirect("/path");
});
};
return (
<div>
<input value={name} onChange={(e) => setName(e.target.value)} />
<button onClick={handleSubmit} disabled={isPending}>
Update
</button>
</div>
);
}
React 19 允许在 startTransition 里传异步函数,这是和 React 18 最大的区别。React 18 的 transition 只接受同步回调,异步逻辑得自己在外面裹一层状态。
Action 帮你托管的东西一共四类。pending 状态在请求发起时自动置位、结束时自动复位;配合 useOptimistic 可以在等待期间先把界面改掉;请求抛错时会交给最近的 Error Boundary,同时把乐观更新回滚回去;如果是通过 <form action> 提交的,成功之后表单会自动 reset。
# 三、useActionState 与 useFormStatus
useActionState 是 Actions 的常用封装,把「提交函数 + 返回值 + pending」打包成一个 Hook。
import { useActionState } from 'react';
function ChangeName() {
const [error, submitAction, isPending] = useActionState(
async (previousState, formData) => {
const error = await updateName(formData.get("name"));
if (error) {
return error; // 返回值会成为下一次的 state
}
redirect("/path");
return null;
},
null // 初始 state
);
return (
<form action={submitAction}>
<input type="text" name="name" />
<button type="submit" disabled={isPending}>
{isPending ? '提交中...' : '更新'}
</button>
{error && <p style={{ color: 'red' }}>{error}</p>}
</form>
);
}
第一个返回值叫 error 只是这个例子里的命名习惯,它的实际含义是「上一次 Action 的返回值」。你返回校验对象、返回成功提示,它就是那个东西。第二个是包装后的 action,直接挂到 <form action> 上;第三个是 pending。
Action 函数的第一个参数是上一次的 state,第二个才是 formData。很多人可能没注意到这一点,写成只接 formData 的单参数函数,结果拿到的是上一次的返回值。
useFormStatus 解决的是另一个问题,设计系统里的提交按钮是个独立组件,它需要知道外层表单在不在提交中,但你又不想为此加一路 props。
import { useFormStatus } from 'react-dom';
function SubmitButton() {
const { pending, data, method, action } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? '提交中...' : '提交'}
</button>
);
}
function MyForm() {
return (
<form action={async () => { /* ... */ }}>
<input type="text" name="username" />
{/* 子组件自己去读表单状态,不用父组件透传 */}
<SubmitButton />
</form>
);
}
它从 react-dom 导入而不是 react,因为这是 DOM 特有的能力。另外它只能读到父级 <form> 的状态,在 <form> 本身所在的那个组件里调用是拿不到的,必须放在子组件里。
# 四、useOptimistic 让等待期间界面先动起来
网络慢的时候,点了按钮界面纹丝不动,用户第一反应是再点一次。乐观更新就是为了填这段空白。