過去三天,我們一直在回答同一個問題:每次擊鍵,誰該被通知?Day 12 把狀態從表單本體搬進 control;Day 13 看了訂閱關係的兩種寫法,RHF 讀了就算,TanStack Form 寫了才算;昨天則把擊鍵拉成一串,看通知怎麼在元件樹裡擴散。
三種寫法有一個共同的前提:使用者每打一個字,都得有人知道。
可是表單最後要做的事,是把填好的值送出去。送出之前的每一下擊鍵,真的都需要有人知道嗎?
今天是探索使用者輸入的最後一天,就讓我們接著昨天留下的問題,看看如果打字時沒有任何人被通知,表單還能不能把值送出去。
遠端世界 ✓ · 共享狀態 ✓ · 使用者輸入 ✓ · 瀏覽器 API · 互動行為 · 真實 DOM
回頭看昨天的幾張表。讀取時記下的版本裡,三個欄位從頭到尾都沒有被通知,因為它們不讀 control,值一直留在 <input> 裡。
被通知過的元件只有兩種:讀 isDirty 的送出按鈕,以及讀 errors.email 的錯誤訊息。它們之所以要知道每一下擊鍵,是因為表單想在使用者打字的時候,就先對他說些什麼:這份表單改過了、這個 Email 少了 @。
假使這份表單不需要在打字時說任何話,只需要在最後把值送出去呢?
那麼,打字的過程中,就沒有元件需要讀到這些值。值本來就在 <input> 裡,真正需要讀它的,只有送出的那一刻。
問題只剩下一個:送出的那一刻,要怎麼把值拿出來?
這件事,瀏覽器本來就會做。<form> 送出時,會把裡面每一個帶 name 的欄位收成一份 FormData。
React 19 讓 <form> 的 action 可以直接接一個函式,以下稱為 form action。表單送出時,React 呼叫這個函式,並把那份 FormData 交給它。我們可以用它,把 Day 12 那份三欄位表單改寫成這樣。欄位只留 name,不交 value,也不接 onChange;send 則用一秒的等待,假裝成一次送到伺服器的請求:
import { useFormStatus } from "react-dom";
const send = async (formData: FormData): Promise<string> => {
await new Promise((resolve) => setTimeout(resolve, 1000));
return `已送出:${String(formData.get("name"))}`;
};
const Field = ({ name, label }: { name: string; label: string }) => {
console.log(`${label} 渲染`);
return (
<label>
{label}
<input name={name} />
</label>
);
};
const SubmitButton = () => {
const { pending } = useFormStatus();
console.log("送出按鈕 渲染");
return (
<button type="submit" disabled={pending}>
送出
</button>
);
};
export const SignupForm = () => {
console.log("表單 渲染");
return (
<form
action={async (formData) => {
await send(formData);
}}
>
<Field name="name" label="姓名" />
<Field name="email" label="Email" />
<Field name="message" label="留言" />
<SubmitButton />
</form>
);
};
送出按鈕用 useFormStatus 讀出 pending,也就是它所在的那個 <form> 是不是正在送出。送出的過程中,按鈕是停用的。
掛載時,主控台印出五行,每個元件各一次。接著在姓名欄打下 L,再打一個 e,主控台都沒有新的輸出。
打字的時候,React 的元件樹沒有收到通知。
然後按下送出,主控台印出:
送出按鈕 渲染
一秒之後,send 回來了,主控台又印出同一行,按鈕恢復可以按,姓名欄也被清空了。send 收到的 FormData 裡,name 是 Le,也就是送出那一刻 <input> 裡的值。
這一版沒有 control,沒有 subscribe,也沒有任何一個 useState。值從頭到尾由 <input> 保管,送出時由瀏覽器收成 FormData,React 手上只有一個 pending。
姓名欄被清空,則是 React 做的:action 的函式成功結束之後,React 會把表單裡非受控的欄位重設回預設值。這份表單沒有給預設值,所以清成了空白。
不過,send 交回的那一句「已送出:Le」,現在沒有人接。使用者按下送出之後,只看得到按鈕停用了一秒。
要把結果畫出來,React 19 另外提供了 useActionState。它收一個函式與一個初始值,交回目前的結果,以及一個可以直接交給 <form action> 的函式;表單送出時,React 會把上一次的結果與這次的 FormData 一起交給我們的函式,它的回傳值就成為新的結果。
send、Field 與 SubmitButton 都不動,只改寫 SignupForm,並加上一個顯示結果的 Result:
import { useActionState } from "react";
const Result = ({ message }: { message: string | null }) => {
console.log("送出結果 渲染");
return <p role="status">{message}</p>;
};
export const SignupForm = () => {
const [message, action] = useActionState<string | null, FormData>(
(_previous, formData) => send(formData),
null,
);
console.log("表單 渲染");
return (
<form action={action}>
<Field name="name" label="姓名" />
<Field name="email" label="Email" />
<Field name="message" label="留言" />
<SubmitButton />
<Result message={message} />
</form>
);
};
同樣打下 L、再打一個 e,主控台仍然沒有輸出。
然後按下送出,主控台印出:
表單 渲染
姓名 渲染
Email 渲染
留言 渲染
送出按鈕 渲染
送出結果 渲染
一秒之後,同樣的六行又出現一次,畫面上多了一行「已送出:Le」。
pnpm test 把兩個版本並排,印出每一步通知了誰。兩個欄名分別是:
useActionState 接住回傳值的版本<form action> 直接接一個函式,回傳值沒有人接的版本打字的兩步,兩個版本整張表都是「—」。「按下送出」那一步是這樣:
| 元件 | useActionState | 只有 form action |
|---|---|---|
| 表單本體 | 通知 | — |
| 姓名欄 | 通知 | — |
| Email 欄 | 通知 | — |
| 留言欄 | 通知 | — |
| 送出按鈕(讀 pending) | 通知 | 通知 |
| 送出結果(讀 action 的回傳值) | 通知 | — |
「送出完成」那一步,兩欄都跟這張表相同。
useActionState 那一欄,看起來很眼熟。表單本體與底下每一個元件都被通知,跟 Day 12 那份 useState 表單一模一樣。
原因也一樣。useActionState 的結果,以及它自己記著的「是不是正在送出」,都是 SignupForm 的狀態;SignupForm 一重新渲染,底下的元件就跟著重新渲染。
只有 form action 那一欄則不同。表單本體沒有持有任何狀態,pending 只有讀 useFormStatus 的送出按鈕拿得到,所以被通知的只有它。兩個版本的欄位與按鈕完全相同,差別只在回傳值由誰接住。
那麼,useActionState 那一欄,是不是又回到了 Day 12?
差別在於它發生在什麼時候。Day 12 的那張表,每一下擊鍵都會重複一次;使用者打一個名字,它就重複好幾次。今天的這張表,一次送出只出現兩次:按下送出的那一刻,以及結果回來的那一刻。
而那兩刻,使用者本來就在等畫面改變:按鈕該停用,結果該出現。
真的想讓欄位不被通知,也做得到,只有 form action 那一欄就是一種作法。但對一件一次送出只發生兩次的事,「誰該被通知」這個問題,重量跟每秒發生好幾次的時候不太一樣。
送出是一次性的。一次性的事,比較不需要切出訂閱的粒度。
這也是今天的表單跟前三天不太一樣的地方。前三天的欄位與按鈕,從掛載起就一直訂閱著 control,每一次推送都要回答一次「我要不要重新渲染」。今天沒有這樣的訂閱:pending 雖然一直能被讀取,但它真正變動的時刻只有送出開始與送出結束。
把這四天放在一起,這個領域的複雜度,到底是從哪裡長出來的?
不在欄位的數量。Day 12 的表單只有三個欄位,每一下擊鍵仍然是整份表單重新計算。
三個欄位其實不多,但一次輸入名字,通知可能發生十幾次。問題不是結構有多大,而是同一件事要重複回答多少次。
變動頻率高到一個程度,通知在元件樹裡怎麼擴散,就被看見了。這是頻率的複雜度,不是結構的複雜度。
而今天的 form action,打字時根本不產生通知,也就沒有那張表可以畫。它沒有回答「誰該被通知」,而是讓這個問題在打字時不出現。
用 Day 02 的詞來說,三份答案藏起來的東西並不相同:
| 答案 | 藏起來的東西 |
|---|---|
| RHF | 訂閱關係本身:元件讀過什麼,就訂閱什麼 |
| TanStack Form | 幾乎沒有:選擇器由呼叫端自己寫,也要寫對 |
| form action | 「還需不需要訂閱」這個問題 |
Day 13 的結尾說過,RHF 把關係藏在讀取過程裡。呼叫端看不到這份關係,也不必寫它。TanStack Form 則把關係攤在呼叫端眼前,代價是選擇器寫錯了,就會訂閱錯東西。form action 藏的不是關係,而是讓「要不要有這份關係」不必被問起。
共享狀態那幾天,三份答案排得成一條線:從選擇器、到節點、到讀取軌跡,藏起來的東西一份比一份多。這裡的三份,藏的不是同一種東西,沒有辦法排在同一條線上。
那麼,這個領域的深度在哪裡?
不在欄位登記。在 control 裡,登記一個欄位只要六行:放進名冊,交回一個把它拿出名冊的函式。
真正費力的,是訂閱關係什麼時候建立、又活多久。回頭看 Day 13 的 useFormState:
useLayoutEffect 裡掛上的,也就是這次渲染的結果套用到畫面之後,才開始生效。useLayoutEffect 交回的清理函式把訂閱撤掉。而 Day 13 也看到了,渲染期間記下的帳只會變長,不會變短。訂閱關係的生命週期,比元件的程式碼看起來的還要長。
TanStack Form 的 useSelector 也逃不開同樣的時間點:useSyncExternalStore 在畫面更新之後才呼叫 subscribe,元件卸載時退訂;每一次推送,選擇器都要重跑一次。
如果只看訂閱的生命週期,兩邊都不輕。不同的,是呼叫端要交出去的東西。
再把它們排一次:RHF 的 useFormState 只收一個 control,其餘什麼都不用寫;TanStack Form 的 useSelector 要一個 control、一個選擇器,必要時再加一個比較函式。
用 Day 02 的詞來說,兩者的介面寬度是相反的。
而介面後面承接的東西,一邊是每次渲染重新記帳、而且得決定什麼時候生效與撤銷;另一邊是每次推送重跑選擇器與比較,同樣得決定什麼時候生效與撤銷。兩份的深度,看起來相差不多。
兩個都深,深在不同地方。
Day 07 的兩份答案,介面寬度相近,深度不同;Day 11 的三份答案,介面越來越窄,深度越來越深。前兩次,至少還說得出哪一份承接得比較多。這一次,我說不出來。
要拿什麼來比較這樣的兩份答案,我現在還沒有答案。
這四天問的都是同一件事:每次擊鍵,誰該被通知。答案從整份表單重新計算,走到讀了就算與寫了才算,再走到連續擊鍵下使用者看得到的東西;最後,走到打字時也許根本不必有人知道,送出時讀一次就好。
今天的表單裡,值一直由瀏覽器保管,React 只在送出的那一刻讀它一次。
瀏覽器替我們保管的,不只是輸入框裡的字。
如果它們也根本不需要持續知道呢?
💡 文中的
send只是用一秒的等待假裝成請求。這兩個版本也只做到pending與回傳值:action丟出錯誤時怎麼辦、useOptimistic的樂觀更新、交給伺服器函式的漸進增強,以及手動重設表單的requestFormReset,這裡都沒有處理。本文對照
react-hook-form7.87.0、@tanstack/react-form1.33.5,查核於 2026-08-31;<form action>、useActionState與useFormStatus的行為依 React 官方文件,查核於 2026-09-30;實驗跑在react19.3.0 與react-dom19.3.0,查核於 2026-09-30。