原文:https://dev.to/shubhradev/react-19-actions-i-explained-3-hooks-without-ever-explaining-what-an-action-is-m79(作者 @shubhradev)
这个系列写到第三篇,如果让我给教给大家的每一个Hook下方的那个核心概念下个定义,我不确定自己之前能否说得清楚。这不是谦虚。回头看看第一到第三篇。我反复使用“Action”这个词,却从未停下来说清楚它到底是什么。
第一篇 开篇提到了我多年来手动管理的三个状态变量:一个结果、一个进行中标志、一个错误状态,然后展示了 useActionState 如何将这三者合并为一次调用。第二篇 用一个评论框作为例子,在服务器响应之前就让它感觉立刻完成了。第三篇 则让一个距离三层组件之外的提交按钮,无需传递任何 props 就能读取表单的进行中状态。三个不同的问题,三个不同的Hook,但它们都悄悄依赖着同一个底层机制,而我从未明确点破。
这篇文章就是要填补这个空白。这次不是一个新的bug,而是过去三个bug所共同指向的那个核心概念。
我一直使用却未曾定义的这个词
这是直接来自 React 官方文档的真实定义,我一字未改。一个在 startTransition 内部被调用的函数就是一个 Action。就这些。这就是全部的判定标准。
async function submitFeedback(previousState, formData) {
const message = formData.get("message");
await saveFeedback(message);
return { sent: true };
}
上面这个函数可能永远存在于你的代码库中,却从未真正像一个 Action 那样运行。触发其转变的关键与函数内部的内容无关,完全取决于它穿过的“门”。有四种“门”可以做到这一点:直接调用 startTransition、useTransition 提供的类似调用、表单的 action 属性接受一个函数(而非字符串),以及 useActionState。只要让一个函数穿过其中任何一个“门”,它就不再是你手动追踪的一个普通异步函数。React 会自动接管的是 Transition(过渡)本身,以及底层挂起状态的追踪。但它并不总会给你函数的返回值。useTransition 只给你 isPending,仅此而已;直接调用 startTransition 则根本不返回任何东西。useActionState 是唯一一个被设计用来捕获返回值并将其作为状态保存的“门”,这正是为什么在第一篇中 isPending 和真正的结果会同时出现,而本系列其他地方都没有。关于执行顺序的更多内容,等到后面详细讲 useTransition 时再说。
一旦我理解了这一点,第一到第三篇看起来就不再是三个独立的Hook,而像是从三个不同窗口观察同一个房间。useFormStatus 读取最近父表单的挂起状态,现在你知道了为什么这种挂起状态会存在——那是因为某个地方把提交操作包装在了一个 Transition 中。useOptimistic 则是个例外。它根本不是进入成为 Action 的那扇门。它是一个在已经运行的 Action 内部调用的设置函数。如果你在 Transition 之外调用它,它根本不会表现出第二篇中展示的那种行为。
为什么需要发明这些东西
第一篇的开篇之言依然成立:每个表单都需要同样的三段状态,每次都手动连接。这种模式并非表单独有。一个删除按钮、一个关注切换、一个结账步骤,都需要从头重建完全相同的脚手架。React 19 首次添加了在 Transition 内部使用异步函数的能力,这是本系列所有其他内容的基础。你在这个基础上构建什么——无论是获得一个被追踪的结果,还是仅仅一个挂起标志——取决于你实际穿过的是那四扇门中的哪一扇。useActionState 是其中走得最远的,它将返回值捕获为状态。其他的设计上给予你的就更少。
<form action={fn}>,以及没人告诉你的三件变化之事
在 Action 出现之前,表单运行代码的唯一真正途径是 onSubmit,而这条路径附带了你每次都需要记住的要求:调用 preventDefault,从事件目标手动构建 FormData,以及手工管理加载和错误状态。
function OldForm() {
function handleSubmit(e) {
e.preventDefault();
const formData = new FormData(e.target);
// 发起请求,并自行管理加载和错误状态
}
return <form onSubmit={handleSubmit}>{/* ... */}</form>;
}
将 onSubmit 替换为直接传递给 action 的函数,其心理模型的变化比语法提示的要大。首先,不再需要调用 preventDefault——这并非因为你忘记了,而是因为 React 在表单触发之前就已知道这是一个 Action,因此浏览器默认的整页刷新根本就不会发生。其次,该函数也不再接收事件对象,而是直接接收 FormData,它已从页面上所有命名字段构建完成,这省去了你过去每次都要手动编写的一个步骤。最后,请求方法本身也不再需要你来配置了。传递给 action 的函数始终以 POST 方式提交,无论你旁边的 method 属性写了什么。最后这一点正是第 3 部分从 useFormStatus 那边遇到的情况,当时无论我预期如何,method 返回的总是 'post'。因为它读取的从来不是那个属性,而是其背后的 Action。
onSubmit 仍有一个特定用途值得保留。任何需要在提交开始前阻止它的情况——比如检查两个密码字段是否匹配——都应该放在这里。因为当 Action 内部的代码执行时,提交早已开始,所以其中的任何代码都无法阻止它。
同步 Action 存在。只是它们没什么意思
一个 Action 不必是异步的。一个传递给相同四个入口的普通同步函数同样算数,React 仍会管理其生命周期,但其 pending 窗口通常太短,无法围绕它构建 UI。
function resetSearch(previousState, formData) {
return { query: "" };
}
一旦那个函数内部出现 await,从第 1 到第 3 部分的所有内容就都变得有价值了。React 18 要求 startTransition 的回调必须是同步的。React 19 解除了这一限制,而正是这个单一改变,使得 useActionState、<form action={fn}> 和 useTransition 都可以直接接受异步函数,而无需你手动管理异步边界。
客户端 Action 和服务器函数不是同一个词说了两遍
到目前为止,本系列的所有内容都是客户端 Action,即无论是否与服务器通信,都在浏览器中运行的代码。该版本在任何 React 19 设置中都能以完全相同的方式工作,无需框架。
服务器函数是一个相关但本质独立的概念,它们仅存在于支持服务器组件的框架中,Next.js 是一个明显的例子。React 自己的文档对此关系描述得很具体,值得正确理解,而不是像我起草本文时差点做的那样将两个术语互换使用:一个服务器函数只有在被传递给 action prop 或从 Action 内部调用时,才会变成服务器动作。并非每个服务器函数都是服务器动作。每个以这种方式使用的服务器动作都是一个服务器函数。
"use server";
export async function submitFeedback(formData) {
const message = formData.get("message");
await db.feedback.create({ message });
}
这个单独的 formData 参数,是当服务器函数被直接传递给表单的 action prop 时所接收的参数形式。值得特别指出,因为它不能直接与 useActionState 互换使用。useActionState 总是以 (previousState, formData) 两参数的形式调用其函数,这是第 1 部分介绍的形式。通过 useActionState 复用服务器逻辑意味着从一开始就要为该形式编写代码,而不是投入一个单参数版本并希望 React 能为你适配。它不会适配。
第四扇门:useTransition
在整个系列中,useTransition 这个名字从未出现过一次,但它所暴露的机制其实在每个示例里都一直在底层运行。调用它会精确地返回两样东西:一个 isPending 标志和一个 startTransition 函数。
到目前为止,所有的结账示例都运行在 <form> 内部,而表单会自动将其 Action 包裹在 Transition 中。useTransition 则适用于没有表单可以依赖的场景。想象一个商品卡片上的收藏心形图标,附近没有任何表单,只有一个点击处理器需要向服务器发送请求并报告是否处于挂起状态。
import { useTransition, useState } from "react";
function WishlistButton({ productId, isSaved, toggleWishlist }) {
const [isPending, startTransition] = useTransition();
const [saved, setSaved] = useState(isSaved);
function handleClick() {
startTransition(async () => {
setSaved(!saved);
const result = await toggleWishlist(productId);
startTransition(() => {
setSaved(result.saved);
});
});
}
return (
<button onClick={handleClick} disabled={isPending} aria-pressed={saved}>
{saved ? "♥ Saved" : "♡ Save"}
</button>
);
}
注意那个在 await 之后包裹着 setSaved 的、嵌套的第二个 startTransition。如果觉得眼熟,那很正常——这和第二部分评论框中 onConfirmed 被其自身的 startTransition 包裹的模式完全相同。这并非你偶然看到的两次独立模式。React 只会自动跟踪同步工作作为 Transition 的一部分。任何在 await 之后的代码都需要自己的 startTransition 调用才能继续被计入,无论你是在评论表单内部,还是在一个看不到表单的心形图标里。
同样请注意,上面的收藏按钮使用的是普通的 useState,而不是 useOptimistic。这并非疏忽。这个示例的存在是为了完全独立地展示 useTransition 本身所能提供的功能,再引入其他三个 hooks 之前。在一个真实应用中,你几乎肯定会在这里改用 useOptimistic,这正是它在第二部分中被设计出来要解决的即时反馈任务。先展示使用裸 useState 的版本,是看清 useTransition 独立作用的唯一方式。
这部分容易忽略,且若以错误方式发现则代价高昂。快速点击、取消点击、再点击那个心形,原始的 useTransition 调用无法保证哪个结果最后落地。React 明确表示:Transition 中的 Action 本身并不保证执行顺序。一个容易产生的、而我差点在确认前就写进这一节的假设是:<form action={fn}> 与裸的 useTransition 调用一样,也存在同样的问题。但事实并非如此。React 自己的文档将 <form> 操作与 useActionState 归为两种内置方式,它们已经为你处理了排序问题。只有裸的 useTransition,在没有其他东西包裹的情况下单独使用时,才没有这样的保证,上面的收藏按钮也不例外。如果你一直认为这个保证来自 Action 本身,那它其实没有。它是 useActionState 和 <form> 操作在原始机制之上特别添加的。
四者实际的适用场景对照
| 你需要... | 应该使用 |
|---|---|
| 在一个地方管理表单的结果和待处理状态 | useActionState |
| 在服务器确认之前就展示完成的 UI | useOptimistic |
| 从一个不管理表单的组件中读取表单的待处理状态 | useFormStatus |
| 在表单之外运行一个 Action 并追踪其待处理状态 | useTransition |
它们从来不是对“该学哪个 hook”这个问题的四个竞争性答案。它们是四个不同问题的答案,只是底层共享了一个机制。第三部分的结账表单已经通过跨四个独立组件组合使用其中两个来证明了这一点:useActionState 负责提交,useFormStatus 让 PlaceOrderButton 能读取待处理状态,而无需向下传递任何 props。下面是同一个订单表单,叠加了第三个 hook——一个促销码,在表单提交时立即应用折扣,无需等待服务器确认。
import { useActionState, useOptimistic } from "react";
import { useFormStatus } from "react-dom";
async function placeOrder(previousState, formData) {
const result = await submitOrder(formData);
return { orderId: result.id, error: null };
}
function PlaceOrderButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? "Placing order..." : "Place order"}
</button>
);
}
function OrderForm({ cartTotal }) {
const [state, formAction] = useActionState(placeOrder, {
orderId: null,
error: null,
});
const [displayTotal, applyDiscount] = useOptimistic(
cartTotal,
(current, discount) => current - discount,
);
function handleSubmit(formData) {
if (formData.get("promoCode") === "SAVE10") {
applyDiscount(10);
}
formAction(formData);
}
return (
<form action={handleSubmit}>
<p>Total: ${displayTotal.toFixed(2)}</p>
<input name="promoCode" placeholder="Promo code" />
<PaymentFields />
<PlaceOrderButton />
</form>
);
}
useActionState 依然像第三部分那样负责真正的提交。useOptimistic 在表单提交且输入框中包含 SAVE10 时,立即显示折扣后的总价,无需等待 submitOrder 确认。一旦 Transition 结束,它会自动回退到 cartTotal 的当前值。这个回退细节比它看起来更重要。这个精简版本在 submitOrder 解决后从未真正更新 cartTotal,因此折扣会在 Action 结束的瞬间消失——这和第二部分讨论 onConfirmed 时遇到的陷阱一样。一个真正的版本需要父组件在订单确实完成时,将已确认的折扣总价合并回 cartTotal,否则乐观值没有真实的状态可以回退。useFormStatus 依然能让 PlaceOrderButton 在无需从 OrderForm 向下传递任何 props 的情况下自行禁用。三个 hook,一个表单,各司其职,互不干扰。
我差点在这个系列中埋下的错误
我认为这个系列无意中设下了一个陷阱。如果每篇文章只教一个 hook,连续三篇,一个细心的读者可能会得出结论:真正的任务是从中选择最爱,是 useActionState 还是 useOptimistic 还是 useFormStatus,仿佛在一个表单中只能使用其中一个。这从来不是事实。组合使用才是关键。一个真实的表单很少只使用其中一个。它会根据实际需要,使用其中两三个来解决手头的问题,它们之间不存在竞争关系。
我故意倒着构建这个系列,坦白说,在讲解底层概念之前,先给出了三个具体的 bug,因为我认为一旦你已经感受过问题本身的样貌,概念就会更牢固。但这只有在概念最终确实出现的情况下才成立。就是现在这一部分。
如果你想了解更多细节,比如同步与异步 Action 的深入对比、完整的 Server Functions 章节,以及一份详尽的 FAQ,我在我的网站上写了完整版:React 19 Actions Explained。
如果你更愿意先测试一下自己记住了多少,我也制作了一个涵盖这四个 API 的 15 题小测验:React 19 Actions Quiz。
所以,真正的问题来了,也是我真心希望得到答案的问题:这四个 hook 中,有哪些你已经在无意中使用了,却不知道自己其实在调用一个 Action?一个你觉得比 onSubmit 更简单而接入的 <form action={fn}>,一个你从别处复制过来却没细究其作用的 startTransition 调用,一个在你用 useTransition 包裹它之后就不再卡顿的按钮,而你从未追问为何它解决了问题。请在下方分享。我很好奇我们中有多少人已经使用这个机制数月,却一直叫不出它的名字。
原文:https://dev.to/shubhradev/react-19-actions-i-explained-3-hooks-without-ever-explaining-what-an-action-is-m79(作者 @shubhradev)



