我在 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) 呼叫這個函式,而它回傳的值就會成為新的 stateisPending 則是綁定在 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 狀態綁到真正的事件上,所以你以為已經修好的東西,才真的被修好了。

我已經把完整解析寫在我的網站上了,包括帶欄位層級驗證的完整正式版註冊範例、與 useStateuseFormStatus 的左右對照表,以及關於 aria-busy 和在 pending 狀態下停用輸入欄位的無障礙細節: React 19 useActionState Explained

老實說,我本來以為自己已經懂這個模式了,直到我真的開始測試它。結果問題根本不在 React,而是在於我多年來一直是怎麼管理 pending 狀態的。

如果你一直依賴自己維護的 pending 狀態來處理表單送出,真的很值得再重新看一次。我知道自己在看清楚實際發生的事情時,確實感到很意外。


原文出處:https://dev.to/shubhradev/react-19s-useactionstate-showed-me-why-disabling-my-submit-button-was-never-enough-53jd


精選技術文章翻譯,幫助開發者持續吸收新知。

共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。
🏆 本月排行榜
🥇
站長阿川
📝10   💬1   ❤️6
319
🥈
我愛JS
📝2   💬6   ❤️3
114
評分標準:發文×10 + 留言×3 + 獲讚×5 + 點讚×1 + 瀏覽數÷10
本數據每小時更新一次
📢 贊助商廣告 · 我要刊登