這個系列已經來到第三篇了;如果你問我,要我定義出每個我教過你的 Hook 底下那個詞,我不確定我能不能清楚地說出來。這不是故作謙虛。回頭去看第 1 到第 3 部分吧。我一直不斷使用「Action」這個詞,卻從來沒有停下來說明它到底是什麼。
第 1 部分 一開頭就提到我多年來手動寫好的三個狀態變數:結果、pending 標記、錯誤,並展示 useActionState 如何把這三個東西濃縮成一次呼叫。 第 2 部分 把留言框做成在伺服器回應前就能立刻有反應的體驗。 第 3 部分 則讓距離提交按鈕三層元件之外的地方,也能在完全不傳 props 的情況下讀取表單的 pending 狀態。三個不同的問題、三個不同的 Hook,而它們每一個其實都默默依賴著同一套機制,只是我從來沒有把它說出口。
這篇文章要補上的,就是這個缺口。這次不是新的 bug,而是前面三個 bug 共同指向的概念。
先直接講 React 官方文件裡的真正定義,不是我為了效果而改寫的說法。任何在 startTransition 裡呼叫的函式,都叫做 Action。就這麼簡單。這就是全部的判定條件。
async function submitFeedback(previousState, formData) {
const message = formData.get("message");
await saveFeedback(message);
return { sent: true };
}
上面這個函式可以永遠放在你的程式碼庫裡,而它本身卻從來不會自動變成 Action。開關並不取決於函式內容寫了什麼,而是取決於它走進了哪扇門。共有四扇門會讓它成為 Action:直接呼叫 startTransition、useTransition 提供的同樣呼叫方式、把函式而不是字串傳給表單的 action prop,或是使用 useActionState。只要把函式經過這四扇門中的任何一扇,它就不再只是你手動追蹤的普通 async 函式。React 會自動接管的是 Transition 本身,也就是底下的 pending 追蹤;但它不會一律幫你保留函式的回傳值。useTransition 只給你 isPending,不會給別的;單純呼叫 startTransition 甚至不會回傳任何東西。useActionState 是唯一一扇專門把回傳值抓住並保存成狀態的門,這也正是為什麼第 1 部分裡 isPending 和真正的結果會一起出現,而這個系列其他地方都沒有。後面等 useTransition 正式登場時,我會再更完整地說明順序問題。
一旦我想通這件事,第 1 到第 3 部分就不再像三個獨立的 Hook,而開始像是通往同一個房間的三扇不同窗戶。useFormStatus 讀取的是最近父層表單的 pending 狀態,現在你也知道為什麼:因為那個 pending 狀態本來就只會在有東西把提交包進 Transition 時才存在。useOptimistic 則是例外。它不是進入 Action 的門,而是你在一個已經在執行的 Action 裡呼叫的 setter。若在 Transition 外呼叫它,它就不會像第 2 部分展示的那樣運作。
第 1 部分開頭那句話現在看來依然成立:每個表單都需要同樣的三種狀態,而且每次都得手動接線。這種模式也不只存在於表單。刪除按鈕、追蹤按鈕、結帳步驟,全部都需要重新搭出同樣的骨架。React 19 先加入了在 Transition 裡使用 async 函式的能力,這就是整個系列其他內容的基礎。你在這個基礎上能建立什麼、能拿回帶追蹤的結果還是只有 pending 標記,全都取決於你實際走進哪一扇門。useActionState 是走最遠的那個,能把回傳值當成狀態抓回來;其他幾個則是刻意提供更少的資訊。
<form action={fn}>,以及沒人告訴你會改變的三件事在 Action 出現之前,表單能執行程式碼的唯一真正路徑是 onSubmit,而這條路徑每次都會帶來你得記得的幾個步驟:呼叫 preventDefault、從事件目標手動建立 FormData、自己管理 loading 和 error 狀態。
function OldForm() {
function handleSubmit(e) {
e.preventDefault();
const formData = new FormData(e.target);
// 執行請求,自己管理 loading 與 error 狀態
}
return <form onSubmit={handleSubmit}>{/* ... */}</form>;
}
把 onSubmit 換成直接傳給 action 的函式後,心智模型的變化比語法看起來還大。程式不會去呼叫 preventDefault,不是因為你忘了寫,而是因為 React 在表單送出前就已經知道這是一個 Action,所以瀏覽器原本那種整頁重新整理的預設行為一開始就不會發生。函式也不再接收事件,而是直接接收 FormData,而且這份 FormData 已經由頁面上所有有命名的欄位組好了,少掉了你以前每次都得手動寫的一步。至於請求方法本身,則完全不再是你要設定的東西。傳給 action 的函式永遠會以 POST 送出,不管旁邊的 method 屬性寫了什麼。這最後一點,正是第 3 部分從 useFormStatus 的角度遇到的事:不管我原本預期什麼,method 一直回傳 'post'。它從來就不是在讀屬性,而是在讀底下的 Action。
onSubmit 仍然有一個特別適合它的用途。任何必須在提交開始前就阻止送出的事,例如檢查兩個密碼欄位是否一致,就應該放在那裡。等 Action 裡的程式碼開始執行時,提交流程早就已經開始了,所以裡面的任何東西都不可能成為阻止它的那個條件。
Action 不一定要是 async。把一個普通的同步函式交給同樣那四扇門,它依然算 Action,React 也會管理它的生命週期,只是 pending 視窗通常短到不值得拿來做 UI。
function resetSearch(previousState, formData) {
return { query: "" };
}
一旦這個函式裡出現 await,第 1 到第 3 部分講的東西就都變得有用了。React 18 規定 startTransition 的 callback 必須是同步的。React 19 拿掉了這個限制,而正是這個單一改變,讓 useActionState、<form action={fn}> 和 useTransition 都能直接接收 async 函式,而不需要你手動管理 async 邊界。
到目前為止,這個系列講的全都是 Client Action,也就是在瀏覽器裡執行的程式碼,不管底下是不是還會跟伺服器溝通。這個版本在任何 React 19 設定下都能正常運作,不需要框架。
Server Function 則是相關但確實不同的概念,而且只存在於支援 Server Component 的框架裡,最明顯的例子就是 Next.js。React 官方文件對兩者關係說得很清楚,這點最好釐清,不要像我在寫這篇時差點做的那樣,把兩個詞混著用:只有當一個 Server Function 被傳給 action prop,或是從某個 Action 裡呼叫時,它才會變成 Server Action。不是每個 Server Function 都是 Server Action;但每一個被這樣使用的 Server Action 都是 Server Function。
"use server";
export async function submitFeedback(formData) {
const message = formData.get("message");
await db.feedback.create({ message });
}
上面這個單一 formData 參數,就是當 Server Function 被直接傳給表單的 action prop 時所接收的形狀。這點值得特別標註,因為它不能直接跟 useActionState 互換使用;後者永遠會以 (previousState, formData) 這種兩個參數的形狀來呼叫函式,也就是第 1 部分介紹的形式。若要透過 useActionState 重用伺服器邏輯,就代表你一開始就要針對這個形狀來寫,而不是先塞一個只收單一參數的版本,再希望 React 幫你自動適配。它不會。
這個系列裡還沒直接提過 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 ? "♥ 已收藏" : "♡ 收藏"}
</button>
);
}
注意看 await 之後那第二個、巢狀的 startTransition,它包住了 setSaved。如果這看起來很熟悉,那就對了:這和第 2 部分留言框裡 onConfirmed 再次被自己的 startTransition 包起來,是完全同樣的形狀。這不是你剛好兩次看到的兩種不同模式。React 只會自動把同步工作視為 Transition 的一部分;await 之後的任何東西都需要自己再呼叫一次 startTransition 才會繼續被算進去,不管你是在留言表單裡,還是在一個完全沒有表單的愛心圖示裡。
也注意一下,上面的願望清單按鈕用的是單純的 useState,而不是 useOptimistic。這不是疏忽。這個範例的目的,是先把 useTransition 單獨能提供什麼完全攤開來看,在其他三個 Hook 介入之前先把邊界釐清。在真正的應用裡,你很可能會在這裡直接使用 useOptimistic,也就是它在第 2 部分被設計出來要做的即時回饋工作。先用最基本的 useState 來寫,才看得出 useTransition 單獨到底做了什麼。
這裡有一個很容易忽略、但一旦踩到就很麻煩的重點。快速地點愛心、取消、再點一次,單純的 useTransition 並不保證最後哪個結果會先到。React 文件直接說得很明白:Transition 裡的 Action 本身不保證執行順序。很容易讓人以為、我差點也寫進這一段的是,<form action={fn}> 跟單純呼叫 useTransition 一樣,也會有同樣的問題。其實不是。React 官方文件把 <form> action 和 useActionState 歸為兩種已經替你處理好順序的內建方式。真正沒有這種保證的是單獨使用的 raw useTransition,上面的願望清單按鈕也一樣。如果你一直以為這個保證是所有 Action 都有的,那就不是;它其實是 useActionState 和 <form> action 在原始機制之上額外提供的能力。
| 你需要做的事 | 應該用 |
|---|---|
| 把表單的結果和 pending 狀態一起放在同一處管理 | useActionState |
| 在伺服器確認前先顯示完成後的 UI | useOptimistic |
| 從沒有在管理該表單的元件中讀取表單的 pending 狀態 | useFormStatus |
| 在表單外執行 Action 並追蹤其 pending 狀態 | useTransition |
它們從來不是四個互相競爭的答案,不是在回答「我該學哪個 Hook」。它們是四個回答四個不同問題的工具,而這些問題剛好共用同一套底層機制。第 3 部分的結帳表單已經證明了這點:它把兩個 Hook 分散在四個不同元件中一起使用,useActionState 負責提交,useFormStatus 則讓 PlaceOrderButton 在完全不傳 props 的情況下讀取 pending 狀態。下面是同一個訂單表單,再疊上一個第三個 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 ? "正在送出訂單..." : "送出訂單"}
</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>總計:${displayTotal.toFixed(2)}</p>
<input name="promoCode" placeholder="優惠碼" />
<PaymentFields />
<PlaceOrderButton />
</form>
);
}
useActionState 仍然像第 3 部分那樣負責真正的提交。useOptimistic 會在表單提交且欄位中是 SAVE10 的那一刻,立刻顯示折扣後的金額,不必等 submitOrder 先確認任何事情,而且當 Transition 結束時,它會自行回到目前的 cartTotal。這個回退細節比看起來更重要。這個簡化版本在 submitOrder 解決後其實沒有真的更新 cartTotal,所以折扣會在 Action 完成的瞬間消失,這和第 2 部分裡 onConfirmed 的坑完全一樣。真正的版本必須由父層在訂單真的成立後,把確認過、含折扣的總額寫回 cartTotal,不然 optimistic 值根本沒有真正可以落地的目標。useFormStatus 仍然能讓 PlaceOrderButton 在完全不靠 OrderForm 傳 props 的情況下自行停用。三個 Hook、同一個表單、彼此各司其職,誰也不搶誰的工作。
這個系列不小心埋下了一個陷阱,我是這麼想的。連續三篇各講一個 Hook,仔細看的人很可能會以為真正要做的是選一個最喜歡的:useActionState、useOptimistic、useFormStatus,好像一個表單裡只能放其中一個。這從來不是事實。重點一直都是組合。真正的表單很少只用到這三個中的一個。它會用到當下那個問題真正需要的兩個或三個,而它們根本不是在爭同一個工作。
老實說,我是故意倒著寫這個系列的:先給你三個具體 bug,再講底下的概念,因為我覺得人只有先感受到問題的輪廓,概念才會記得更牢。但前提是,最後真的要把概念說出來。這篇就是那一段。
如果你想看更深入的內容,例如 sync 與 async Action 的更多細節、完整的 Server Functions 章節,以及完整 FAQ,我已經把完整版寫在我的網站上了: React 19 Actions Explained。
如果你想先測試自己到底記住了多少,我也做了一份 15 題的測驗,內容涵蓋同樣四個 API: React 19 Actions Quiz。
所以,這裡才是真正的問題,也是我真心想知道答案的:這四個裡面,你已經在不知不覺間用了哪些,卻還不知道自己其實碰到了 Action?是你因為 <form action={fn}> 比 onSubmit 看起來更簡單而直接用上的表單?是你從某處複製來、卻沒去讀它到底做什麼的 startTransition 呼叫?還是某個按鈕一開始覺得卡頓,直到你用 useTransition 包起來後才順了,卻從沒問過為什麼這樣就好了?歡迎在下方留言。我很好奇,我們當中有多少人其實已經默默用了這個機制好幾個月,卻一直沒有替它命名。