iT邦幫忙

2026 iThome 鐵人賽

DAY 16
0
Modern Web

再造輪子:30 天臨摹 React Hook 函式庫,探索背後的設計哲學系列 第 16 篇

【 Day 15 】提交這件事,真的需要持續訂閱嗎?|使用者輸入(四完)

  • 分享至 

  • xImage
  •  

過去三天,我們一直在回答同一個問題:每次擊鍵,誰該被通知?Day 12 把狀態從表單本體搬進 control;Day 13 看了訂閱關係的兩種寫法,RHF 讀了就算,TanStack Form 寫了才算;昨天則把擊鍵拉成一串,看通知怎麼在元件樹裡擴散。

三種寫法有一個共同的前提:使用者每打一個字,都得有人知道。

可是表單最後要做的事,是把填好的值送出去。送出之前的每一下擊鍵,真的都需要有人知道嗎?

今天是探索使用者輸入的最後一天,就讓我們接著昨天留下的問題,看看如果打字時沒有任何人被通知,表單還能不能把值送出去。

遠端世界 ✓ · 共享狀態 ✓ · 使用者輸入 ✓ · 瀏覽器 API · 互動行為 · 真實 DOM

昨天那張表上,誰真的需要知道

回頭看昨天的幾張表。讀取時記下的版本裡,三個欄位從頭到尾都沒有被通知,因為它們不讀 control,值一直留在 <input> 裡。

被通知過的元件只有兩種:讀 isDirty 的送出按鈕,以及讀 errors.email 的錯誤訊息。它們之所以要知道每一下擊鍵,是因為表單想在使用者打字的時候,就先對他說些什麼:這份表單改過了、這個 Email 少了 @。

假使這份表單不需要在打字時說任何話,只需要在最後把值送出去呢?

那麼,打字的過程中,就沒有元件需要讀到這些值。值本來就在 <input> 裡,真正需要讀它的,只有送出的那一刻。

問題只剩下一個:送出的那一刻,要怎麼把值拿出來?

把值留在 DOM,送出時才拿

這件事,瀏覽器本來就會做。<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:用 useActionState 接住回傳值的版本
  • 只有 form action:<form action> 直接接一個函式,回傳值沒有人接的版本

打字的兩步,兩個版本整張表都是「—」。「按下送出」那一步是這樣:

元件 useActionState 只有 form action
表單本體 通知 —
姓名欄 通知 —
Email 欄 通知 —
留言欄 通知 —
送出按鈕(讀 pending) 通知 通知
送出結果(讀 action 的回傳值) 通知 —

「送出完成」那一步,兩欄都跟這張表相同。

整份表單都被通知了,這是回到 Day 12 嗎?

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:

  • 帳是在渲染期間記下的,元件讀到哪個 key,就記下哪個 key。
  • 訂閱是在 useLayoutEffect 裡掛上的,也就是這次渲染的結果套用到畫面之後,才開始生效。
  • 元件卸載時,useLayoutEffect 交回的清理函式把訂閱撤掉。

而 Day 13 也看到了,渲染期間記下的帳只會變長,不會變短。訂閱關係的生命週期,比元件的程式碼看起來的還要長。

TanStack Form 的 useSelector 也逃不開同樣的時間點:useSyncExternalStore 在畫面更新之後才呼叫 subscribe,元件卸載時退訂;每一次推送,選擇器都要重跑一次。

介面寬度相反

如果只看訂閱的生命週期,兩邊都不輕。不同的,是呼叫端要交出去的東西。

再把它們排一次:RHF 的 useFormState 只收一個 control,其餘什麼都不用寫;TanStack Form 的 useSelector 要一個 control、一個選擇器,必要時再加一個比較函式。

用 Day 02 的詞來說,兩者的介面寬度是相反的。

而介面後面承接的東西,一邊是每次渲染重新記帳、而且得決定什麼時候生效與撤銷;另一邊是每次推送重跑選擇器與比較,同樣得決定什麼時候生效與撤銷。兩份的深度,看起來相差不多。

兩個都深,深在不同地方。

Day 07 的兩份答案,介面寬度相近,深度不同;Day 11 的三份答案,介面越來越窄,深度越來越深。前兩次,至少還說得出哪一份承接得比較多。這一次,我說不出來。

要拿什麼來比較這樣的兩份答案,我現在還沒有答案。

準備邁入瀏覽器 API 的探索

這四天問的都是同一件事:每次擊鍵,誰該被通知。答案從整份表單重新計算,走到讀了就算與寫了才算,再走到連續擊鍵下使用者看得到的東西;最後,走到打字時也許根本不必有人知道,送出時讀一次就好。

今天的表單裡,值一直由瀏覽器保管,React 只在送出的那一刻讀它一次。

瀏覽器替我們保管的,不只是輸入框裡的字。

如果它們也根本不需要持續知道呢?

💡 文中的 send 只是用一秒的等待假裝成請求。這兩個版本也只做到 pending 與回傳值:action 丟出錯誤時怎麼辦、useOptimistic 的樂觀更新、交給伺服器函式的漸進增強,以及手動重設表單的 requestFormReset,這裡都沒有處理。

本文對照 react-hook-form 7.87.0、@tanstack/react-form 1.33.5,查核於 2026-08-31;<form action>、useActionState 與 useFormStatus 的行為依 React 官方文件,查核於 2026-09-30;實驗跑在 react 19.3.0 與 react-dom 19.3.0,查核於 2026-09-30。


上一篇
【 Day 14 】高頻之下,「誰該被通知」還是效能問題嗎?|使用者輸入(三)
下一篇
【 Day 16 】兩個元件用同一個 key,為什麼一邊改、另一邊不知道?|瀏覽器 API(一)
系列文
再造輪子:30 天臨摹 React Hook 函式庫,探索背後的設計哲學 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言