把 React 19 的新 hooks 理解成「少写几行 useState + useEffect」,会在表单、乐观更新和 RSC 边界上立刻失效。useActionState、useOptimistic、use() 不是语法糖,是把异步从副作用里拽回渲染模型:一次 Action 是一次过渡(transition),pending / 错误 / 乐观结果是这条过渡的状态,不是你在组件上另开的三份布尔。use() 让 Promise 和 Context 能在渲染期读,和 Suspense 对齐,和 useEffect 里 then 是两条时间线。
这篇文章只讲这条模型:Action 怎样变成过渡、表单怎样把 FormData 交出去、乐观更新怎样和权威状态对账、use() 为什么对 Promise 引用过敏。读完你应该能独立回答三个问题:useFormStatus 为什么必须写在 <form> 的子组件里、每次 render new Promise 为什么会无限挂起、乐观更新失败时 UI 靠什么回滚。
1. Action 不是又一个 fetch 封装
React 19 里,能传给 <form action>、<button formAction>、或丢进 startTransition 的异步函数,叫 Action。框架保证三件事:
- 调用被包进过渡:紧急更新(输入)不被这次请求堵住,pending 可观测。
- 成功后的 DOM 表单会按规范重置(受控输入另说)。
- 和 Concurrent 渲染兼容:过渡可以被打断,但 Action 的那一次执行不会被「渲染两次」跑成两次提交——提交点在事件,不在 render。
"use client";
async function saveBio(formData: FormData) {
await updateBio(String(formData.get("bio") ?? ""));
}
export function BioForm() {
return (
<form action={saveBio}>
<textarea name="bio" />
<button type="submit">保存</button>
</form>
);
}
没有 onSubmit、没有 preventDefault、没有 fetch 再 setState。浏览器会原生提交;有 JS 时 React 拦下来跑 Action,没 JS 时(Server Action)仍是 POST。这是渐进增强,不是装饰。
自己用 onSubmit 再 e.preventDefault() 再 void fetch(),等于退出这条协议:没有自动 pending,没有自动重置,没有和 useFormStatus 对接,也没有 Server Action 的刷新指令。能跑,只是你把框架已经付过的账又付一遍,还付错层。
2. 过渡才是 pending 的真源
isPending 不是「请求发出去了」。它是:还有未完成的过渡。输入框的 setState 是紧急更新,立刻提交;Action 是非紧急的,可以在 CPU 忙时让路。所以你会看到:按钮已经 disabled,输入仍能打字。这不是 bug,是过渡的定义。
startTransition(async () => { await save(); }) 把任意异步函数收成 Action。useTransition 给出的 isPending 覆盖这次过渡。useActionState 内部就是这条路,外加把返回值收成 state。
不要再用 useEffect 监听 isPending 去「补一次刷新」。过渡结束时 React 已经按 Action 的返回值和 Server Action 的 revalidate 指令更新树。再补一次 router.refresh() 会打出双请求,并把乐观状态窗口拉长。
过渡可重叠。连点两次提交,两次 Action 都会跑——除非你在按钮上 disabled={isPending} 或自己做队列。框架不给你「自动防抖」。连点造成两条写入,是业务 bug,不是 hook 没文档。
3. useActionState:把返回值收成状态
useActionState(action, initialState) 返回 [state, formAction, isPending]。包装后的 formAction 签名变成「先吃上一份 state,再吃 FormData」。所以 Server Action 也必须改签名:
"use server";
export type SaveState = { ok: true } | { ok: false; error: string };
export async function saveName(prev: SaveState, formData: FormData): Promise<SaveState> {
const name = String(formData.get("name") ?? "").trim();
if (name.length < 2) return { ok: false, error: "名字太短" };
try {
await db.user.update({ where: { id: prev && "id" in prev ? 0 : 0 }, data: { name } });
return { ok: true };
} catch {
return { ok: false, error: "保存失败" };
}
}
上面 where: { id: ... } 那种从 prev 里硬挖 id 是错的。id 不该来自客户端可改的 prev。id 来自服务端 session,或来自 bind 的加密闭包。prev 只用来带「上一轮校验错误」,不要当可信输入。
客户端:
"use client";
import { useActionState } from "react";
import { saveName, type SaveState } from "./actions";
const initial: SaveState = { ok: true };
export function NameForm() {
const [state, action, pending] = useActionState(saveName, initial);
return (
<form action={action}>
<input name="name" required />
<button type="submit" disabled={pending}>
{pending ? "保存中" : "保存"}
</button>
{!state.ok && <p role="alert">{state.error}</p>}
</form>
);
}
校验错误走返回值,不要 throw。throw 进最近的 Error Boundary,整段表单卸掉,用户失去输入。可恢复的业务错误是 state;真正的渲染崩溃才是 boundary。
第三参 permalink 给无 JS 的 POST 一个回落 URL。有 JS 时很少用到;做可分享的搜索表单时,permalink 让「提交后的 URL」和 state 对齐。
4. useFormStatus:为什么必须是子组件
useFormStatus() 读的是最近的父级 <form> 的提交状态,靠 React 树,不是靠你把 pending 当 props 传来传去。它必须写在 form 的子树里。写在和 <form> 平级的按钮上,永远是 pending: false。
"use client";
import { useFormStatus } from "react-dom";
function Submit({ children }: { children: React.ReactNode }) {
const { pending, data, method } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? `正在 ${method} ${data?.get("name") ?? ""}` : children}
</button>
);
}
export function FormWithStatus({ action }: { action: (fd: FormData) => void }) {
return (
<form action={action}>
<input name="name" />
<Submit>保存</Submit>
</form>
);
}
data / method / action 是 React 19 补上的。按钮要显示「正在保存张三」,读 data,不要再复制一份 useState。useFormStatus 不能替代 useActionState:它没有上一轮的返回值,只有「此刻这份表单在不在飞」。错误文案仍要 useActionState 的 state,或你自己把 Server Action 的返回值写进 URL。
一个页面两个 form,两个独立的 status。共用一个顶层 isSaving 会让无关按钮一起转圈。这正是 hook 被设计成「读最近的 form」的原因。
5. useOptimistic:先写 UI,提交是另一条时间线
乐观更新的不变量:屏幕上的值 = 权威状态 + 尚未确认的增量。权威状态从 props / server 来;增量在 Action 完成或失败后必须消失。useOptimistic(state, reducer) 把这条不变量收成 API。
"use client";
import { useOptimistic } from "react";
import { send } from "./actions";
type Message = { id: string; text: string; pending?: boolean };
export function Thread({ messages }: { messages: Message[] }) {
const [optimistic, add] = useOptimistic(messages, (current: Message[], text: string) => [
...current,
{ id: "tmp", text, pending: true },
]);
async function formAction(formData: FormData) {
const text = String(formData.get("text") ?? "");
add(text);
await send(text);
}
return (
<div>
{optimistic.map((m) => (
<p key={m.id} data-pending={m.pending ? "1" : "0"}>
{m.text}
</p>
))}
<form action={formAction}>
<input name="text" />
<button type="submit">发送</button>
</form>
</div>
);
}
messages 是权威。Action 期间 reducer 的输出覆盖屏幕。Action 结束,如果父级因 revalidate 传来新的 messages,乐观层卸掉,列表回到权威。失败且父级没变,也卸掉增量——这就是回滚。你不必写 catch { setX(old) }。
坑在两处。第一,key。临时 id 和服务器 id 对不上,卸乐观层时 React 会以为删了一条、又插了一条,闪一下。临时 id 要用稳定算法(客户端 uuid),服务端回显同一 id,或接受闪一下。第二,不可回滚的副作用不要乐观:扣款、发邮件、删不可恢复的文件。乐观只给「加一条消息、点赞、改显示名」这种能撤回的字段。
add() 必须发生在过渡里。放在过渡外,React 不认这是乐观层,会变成真正的本地 state,权威回来时对不上账。form action 和 startTransition 都在过渡里;await send() 之后再 add() 就晚了。
6. use():渲染期读 Promise / Context
use(promise) 在渲染里解包。未决议就 throw 给最近的 Suspense;已拒绝就 throw 给最近的 Error Boundary。use(context) 等价于 useContext,但可以写在条件分支里——这是它和 hooks 规则分道的地方。
import { use, Suspense } from "react";
function Comments({ promise }: { promise: Promise<Comment[]> }) {
const comments = use(promise);
return (
<ul>
{comments.map((c) => (
<li key={c.id}>{c.body}</li>
))}
</ul>
);
}
export function Page({ promise }: { promise: Promise<Comment[]> }) {
return (
<Suspense fallback={<p>加载评论…</p>}>
<Comments promise={promise} />
</Suspense>
);
}
Promise 必须稳定。每次 render use(fetch(...)),每次都是新 Promise,Suspense 永远落不下来,CPU 空转。稳定从哪来:父级 Server Component await 之前把 promise 当 props 传下来(RSC 会缓存这次请求的引用);或 cache() 包过的函数;或 useMemo / state 里保住的那一个。不要在客户端组件的函数体里现场 fetch 再 use。
服务端组件里直接 await 比 use() 简单。use() 的正当位置是:客户端子树要在渲染期读一个已经存在的 Promise,好让 Suspense 边界落在比 page 更小的地方。服务端已经 await 完再当 props 传值,客户端就不必 use()。
不要用 try/catch 包 use() 来处理 reject。拒绝走 Error Boundary。要可恢复 UI,让 Promise resolve 成 { ok: false },或在父级用 error.tsx。
7. 和 Server Action、RSC 的边界
客户端 Action 可以就是一个 async 函数;Server Action 是带 'use server' 的那一种,编译成 POST + 闭包序列化。props 和 bind 进去的值会出现在客户端可见的 payload 里。bind(userId) 不是保密,是方便。保密的 id 来自 cookie / session,在函数体里读。
"use server";
import { revalidatePath } from "next/cache";
import { getSession } from "@/lib/auth";
export async function rename(formData: FormData) {
const session = await getSession();
if (!session) return { ok: false as const, error: "未登录" };
const name = String(formData.get("name") ?? "").trim();
await db.user.update({ where: { id: session.uid }, data: { name } });
revalidatePath("/me");
return { ok: true as const };
}
RSC 负责权威数据:page 里 await getMessages() 再把数组传给客户端 Thread。客户端负责 Action 和乐观层。不要在客户端再 useEffect 拉一份同样的列表——那是第二真源,乐观对账会对到过期的那一份。
闭包捕获了过大的对象(整个 formState、整个 user),序列化变慢,而且可能把不该下发的字段带过边界。Action 参数保持 FormData + 几个标量。
8. 错误、pending、重试
三条错误通道,不要混:
- 返回值。 校验、业务拒绝、可展示的失败。
useActionState的 state。 - Error Boundary。 渲染崩溃、未处理的 throw、
use()的 reject。 - 网络中断。 Action 卡在 pending。React 19 没有把离线做成默认自动重试;按钮应在 pending 时不可点,并给「还在提交」的文案。重复提交靠
disabled,不靠「再写一个 debounce hook」。
重试:把上一次 FormData 留下来再调 formAction。useFormStatus().data 只在飞行中有。落地后要重试,自己 useRef 存一份,或让用户再点一次(输入还在不受控字段里)。受控输入被你在提交时 setValue('') 清掉,失败就无法重试——清值放成功分支。
useActionState 的 state 在下一次成功之前会一直留着上一轮错误。成功时必须返回一份干净的 { ok: true },不要 return prev。
9. 取舍:Action vs 自己 fetch vs 路由跳转
| 路径 | 适合 | 不要用在 |
|---|---|---|
<form action={serverAction}> | 突变、要刷新 RSC、要无 JS 也能提交 | 上传进度条、流式 webhook、非表单 REST |
useActionState + 返回值 | 校验错误要留在页上 | 成功后必须换 URL 的支付回跳(用 redirect) |
useOptimistic | 可回滚字段 | 钱、库存扣减的唯一真源 |
use() | 客户端读已有 Promise / 条件读 Context | 替代 useEffect 发请求 |
自己 fetch + Route Handler | 细进度、取消、非 HTML 协议 | 普通表单——你会把 pending 再造一遍 |
成功后要跳转,在 Server Action 里 redirect()。它抛一个控制流异常,不要 try/catch 把它吃掉。需要「成功仍留在本页」才返回 state。
10. 排障:无限挂起、乐观回不去、表单丢字段
- Suspense 转圈到死。
use()的 Promise 每次 render 都新。把它抬到父级或cache()。 useFormStatus永远 false。 hook 不在 form 子树。把按钮拆成子组件。- 乐观条目闪一下消失又出现。
key从 tmp 换成服务器 id。对齐 id,或接受 remount。 - 乐观停在「发送中」。 父级权威没更新:Server Action 忘了
revalidatePath/updateTag,或客户端自己又持有一份旧messagesstate 当权威。权威必须来自 server props。 - 字段丢失。 受控组件没写
name,FormData是空的。Action 协议走的是原生表单字段,不是 React state。要么 uncontrolled +name,要么提交前手动FormData.set。 - 校验失败整页白屏。 Action
throw了。改成返回值。 - 连点双写。 没
disabled={pending}。加。幂等 key 放服务端。 bind的 id 被篡改。 那不是加密。鉴权以 session 为准。use()在客户端 fetch。 没有稳定引用,也没有 RSC payload。改回 Server Componentawait,或用查询库管缓存。
11. 和缓存失效叠在一起
Action 成功只改了 DB,没有 revalidateTag / updateTag / revalidatePath,RSC 权威不变,乐观层卸完就回到旧列表——看起来像「乐观 bug」,其实是缓存生命周期那篇的层没刷。两个 API 要一起设计:写函数返回新投影,失效函数点名那些 tag。
updateTag 给当前用户读己之写:改完显示名,这次导航就要新的。revalidateTag(tag, 'max') 给其他人 SWR。不要只用 router.refresh() 假装失效——refresh 打当前段,打不到没挂载的详情页的 Data Cache。
useActionState 的 permalink 和 Full Route Cache 是另一处咬合:无 JS 提交会 GET/POST 到 permalink,那条路由必须能渲染「带错误 state」的版本,否则渐进增强只在有 JS 时成立。多数内部工具可以放弃无 JS;对公开表单不要假装支持。
12. 小结
React 19 把突变收成 Action:一次过渡,一份 pending,一份返回值,一份可卸掉的乐观层。useActionState 管返回值和 pending,useFormStatus 管子树里的按钮,useOptimistic 管权威和增量的对账,use() 管渲染期读 Promise。非法状态是:把 Promise 当一次性值在 render 里 new、把业务错误 throw 成崩溃、把不可回滚的事当乐观、把 bind 当保密。和 Next 叠在一起时,Action 的最后一行是失效,不是 console.log。下一篇从样式引擎说:Tailwind v4 把 token 放回 CSS,而不是再生成一份巨型工具类文件。