我在 React 19 之前交付的每一個表單,都需要同樣三個狀態,而我每次都得手動把它們串起來。
一個用來存結果。一個表示是否正在送出。一個用來存錯誤。然後幾乎每個 sprint 至少會有一次,我忘了在正確的位置重置其中一個,接著盯著那顆無法重新啟用的按鈕發呆二十分鐘。
以下是我多年來一直寫的版本,大概也是你現在在十幾個元件裡看到的那一種:
function OldSignupForm() {
const [error, setError] = useState(null);
const [isSubmitting, setIsSubmitting] = useState(false);
async function handleSubmit(e) {
e.preventDefault();
setIsSubmitting(true);
setError(null);
try {
await signup(new FormData(e.target));
} catch (err) {
setError(err.message);
} finally {
setIsSubmitting(false);
}
}
return <form onSubmit={handleSubmit}>{/* ... */}</form>;
}
三個狀態變數。一個 try/catch/finally。以及一個只等著在某天被遺漏 finally 的 bug。
我一直沒覺得這種模式有問題,直到我真的坐下來研究 useActionState,才發現我之前用來「修正」重複送出的 workaround,其實根本沒有真的修好任何事。
我的意思是這樣。大多數人會用下面這種方式來改造表單:
function handleClick() {
setLocalPending(true); // 看起來安全,但其實跟任何真正的狀態都沒綁定
}
在網路很快的情況下,你永遠不會發現它壞掉。但在網路慢的情況下,這個本地旗標可能會在真正的請求還沒完成前就先變回 false。一個連線不穩的 Wi‑Fi 使用者可能會點兩次,甚至三次「提交訂單」,因為按鈕看起來在短暫的一瞬間又可用了。每一次點擊都還是會送出去。React 不會丟掉它們,也不會幫你做競爭處理。它會把它們排隊,然後依照順序一個接一個執行。所以最後不是一筆訂單,而是三筆,而且每一筆都像是有意送出的。
就是在那一刻,我不再覺得 useActionState 只是另一個需要背起來的 hook。它開始讓我感覺,React 其實默默地看著我們把這件事搞錯了夠久,終於把修正方案做出來了。
const [state, formAction, isPending] = useActionState(fn, initialState);
你傳給它一個函式,React 會在表單送出時用 (previousState, formData) 呼叫這個函式,而它回傳的值就會成為新的 state。isPending 則是綁定在 React 自己的 transition 追蹤上,不是你靠猜時間長短來決定的布林值。
一個可運作的範例,不依賴框架,純 React:
import { useActionState } from "react";
async function subscribe(previousState, formData) {
const email = formData.get("email");
if (!email || !email.includes("@")) {
return { success: false, message: "請輸入有效的電子郵件地址。" };
}
await new Promise((resolve) => setTimeout(resolve, 800));
return { success: true, message: "你已訂閱。" };
}
function NewsletterForm() {
const [state, formAction, isPending] = useActionState(subscribe, {
success: false,
message: "",
});
return (
<form action={formAction}>
<input type="email" name="email" placeholder="[email protected]" />
<button type="submit" disabled={isPending}>
{isPending ? "訂閱中..." : "訂閱"}
</button>
{state.message && <p>{state.message}</p>}
</form>
);
}
沒有 onSubmit。沒有 preventDefault。也不用另外維護一個 pending 狀態去同步。把那個假的本地旗標換成 disabled={isPending},你以為自己解掉的重複送出問題,才是真的被解掉了。
我原本以為快速連點會互相競爭。也許最後一次會贏,也許第一次會贏,典型的競態條件場景。結果並不是這樣。
React 會把每一次對 action 函式的呼叫都排隊,並且依序執行。每一次都要等前一次跑完才會開始。快速點四次「加入購物車」,最後穩定下來大約會花四倍的時間,不是因為什麼壞掉了,而是第二次呼叫正在耐心等第一個呼叫的 promise 先 resolve。沒有競爭。也沒有任何東西被悄悄丟掉。
這代表真正的問題從來就不是資料庫寫入被弄亂。問題在於,每一次點擊都還是算數。慢網路下的五次點擊,代表五個已處理的動作,不是一次。disabled={isPending} 並不是用來防止競態,因為根本沒有競態。它是用來阻止使用者把四個他根本不想觸發的動作先排進隊列。
還有一件事,在它咬到你之前值得先知道。如果排隊中的較早一次呼叫丟出錯誤,React 會跳過後面所有仍在等待的呼叫。你應該捕捉錯誤並回傳一個狀態物件,而不是讓任何東西直接 throw,否則你會失去那些你根本不知道還排在後面的動作。
async function submitOrder(previousState, formData) {
try {
const result = await placeOrder(formData);
return { success: true, orderId: result.id };
} catch (err) {
return { success: false, error: "出了點問題,請再試一次。" };
}
}
陳舊閉包。 如果你的 action 函式去讀元件範圍內的變數,而不是直接從 formData.get(...) 取值,那麼只要一次重新渲染,你就可能拿到已經過期的資料。比起依賴前一次渲染捕捉到的值,還是優先從 formData 讀取提交內容。
忘了它沒有重置按鈕。 useActionState 沒有內建的方法可以清除自己的狀態。文件寫得很清楚。如果你需要一個「重新開始」按鈕,你有兩個實際做法:讓 action 函式把重置訊號當成其中一個輸入來辨識,或者改變元件的 key 屬性來強制整個元件重新掛載。通常用重置訊號那條路比較不干擾,因為不用把整個 DOM 拆掉重建。
如果互動需要立刻有感,例如按讚按鈕或核取方塊,任何希望 UI 要在伺服器回應前就先更新的情境,useActionState 總會慢半拍。那是 useOptimistic 的工作,而當你把基本概念弄懂之後,這兩個 hook 很值得一起搭配使用。
而如果是那種跨步驟的多步驟精靈流程,而且各步驟有受控輸入、又不是全部同時掛載,那麼硬要對抗 useActionState 所建立的非受控表單模型,通常只會讓你寫出比省下來更多的程式碼。
你大概一直在用的模式——點擊後禁用,然後希望一切順利——其實是在掩蓋真正的問題,而不是解決它。每一次點擊都還是會送達,不管按鈕看起來是不是被禁用了。useActionState 不只是減少樣板碼。它把你的 pending 狀態綁到真正的事件上,所以你以為已經修好的東西,才真的被修好了。
我已經把完整解析寫在我的網站上了,包括帶欄位層級驗證的完整正式版註冊範例、與 useState 和 useFormStatus 的左右對照表,以及關於 aria-busy 和在 pending 狀態下停用輸入欄位的無障礙細節: React 19 useActionState Explained
老實說,我本來以為自己已經懂這個模式了,直到我真的開始測試它。結果問題根本不在 React,而是在於我多年來一直是怎麼管理 pending 狀態的。
如果你一直依賴自己維護的 pending 狀態來處理表單送出,真的很值得再重新看一次。我知道自己在看清楚實際發生的事情時,確實感到很意外。