昨天的實驗裡,姓名欄只打了兩個字:先打 L,再打 e。兩種訂閱方式都讓送出按鈕只在 isDirty 變了的時候重新渲染。
如果是一次按鈕點擊,通知錯了人,也許只是多做幾次計算。但實際填表的人,不會只打兩個字。一個名字、一個 Email,往往是一連串的擊鍵,打字熟練的人,一秒就有好幾下。control 每收到一下擊鍵就推送一次,所以哪些元件被通知,也會跟著擊鍵一次又一次地重複。
那麼,在這樣的頻率下,「誰該被通知」還只是效能的問題嗎?
讓我們把擊鍵拉成一串,再替表單加上一行錯誤訊息,看看每一步有哪些元件被通知。
遠端世界 ✓ · 共享狀態 ✓ · 使用者輸入 ✍️ · 瀏覽器 API · 互動行為 · 真實 DOM
從 Day 12 到今天,我讓同一份表單跑了四個版本:
useState:單純用 useState 管理狀態的版本四個版本的元件樹相同,都是三個欄位加一顆讀 isDirty 的送出按鈕,只換掉狀態放在哪裡,以及欄位與送出按鈕怎麼訂閱。每一步結束時,測試只記下哪些元件被通知了,不記次數,也不記時間。
我暫且把每一步有哪些元件被通知,稱為這一步的「通知拓撲」 (notification topology)。因為我們接下來要比較的,不是時間快慢、不是次數多寡,而是通知如何在元件樹裡擴散。
在姓名欄打下第一個字時,isDirty 從 false 變成 true;刪回空白時,它又變回 false。連續打字的過程中,除了這兩步,其餘每一下擊鍵都跟「再打一個字」長得一樣。pnpm test 印出的「再打一個字(L → Le)」那一步是這樣:
| 元件 | useState | 人人都聽 | 讀取時記下 | 事先宣告 |
|---|---|---|---|---|
| 表單本體 | 通知 | — | — | — |
| 姓名欄 | 通知 | 通知 | — | 通知 |
| Email 欄 | 通知 | 通知 | — | — |
| 留言欄 | 通知 | 通知 | — | — |
| 送出按鈕(讀 isDirty) | 通知 | 通知 | — | — |
接著,讓我們來看看每擊一次鍵,四個版本通知的元件範圍:
useState:表單本體,以及底下的每一個元件。連續打字時,這張表的情況就會不斷重複出現。
到這裡,看起來還是一筆帳:被通知的元件少一點,重新計算就少一點。
可是,這樣的話,它跟共享狀態那幾天有什麼不同?Day 12 提過:「共享狀態的問題,是通知太廣;使用者輸入的問題,是通知太密。」太密,是不是只是同一筆帳多記了幾次?
回頭看表上被通知的元件。Email 欄與留言欄重新渲染之後,畫出來的還是同一個空白的欄位;送出按鈕重新渲染之後,也還是同一顆可以按的按鈕。它們有沒有被通知,使用者在畫面上看不出差別。
所以只看這張表,它確實比較像是一筆帳。
但表單裡還有一種元件,它一被通知,畫面就會跟著變。
規則只有一條:Email 要有 @。
要讓錯誤訊息出現,control 得先會驗證。我們替 control 的狀態多加一份 errors,並讓建立 control 的人選擇什麼時候驗。
先從制定型別開始,我們從表單記下三種狀態 (FormState):values、isDirty 及 errors。
errors 記的是每個欄位上一次被驗的結果,通過或還沒驗過都是 null。
export type FieldErrors<V extends FormValues> = Readonly<
Record<keyof V, string | null>
>;
export interface FormState<V extends FormValues> {
readonly values: V;
readonly isDirty: boolean;
readonly errors: FieldErrors<V>;
}
我們會在之前使用的 createFormControl 傳入可選的驗證選項 (ValidationOptions) 作為第二個引數,其中驗證模式 (ValidationMode) 有三種:寫入時驗 (onChange)、離開欄位時驗 (onBlur) 以及送出時驗 (onSubmit);另外 validate 則是驗證欄位的函式。
export type ValidationMode = "onChange" | "onBlur" | "onSubmit";
export interface ValidationOptions<V extends FormValues> {
readonly mode: ValidationMode;
readonly validate: (name: keyof V, values: V) => string | null;
}
寫入時,只有 mode 是 onChange 才驗這一個欄位。驗的工作交給 nextErrors,它交回驗完之後的 errors,內容等一下再看。推送的那一行,則從 setValue 裡抽出來成為 push:
const push = (name: keyof V | null): void => {
for (const listener of [...listeners]) listener({ name });
};
const setValue = (name: keyof V, value: string): void => {
if (Object.is(state.values[name], value)) return;
const values = { ...state.values, [name]: value };
state = {
values,
isDirty: differsFrom(values, defaultValues),
errors:
validation?.mode === "onChange"
? nextErrors(values, [name])
: state.errors,
};
push(name);
};
離開欄位與送出,都不會改變值,只可能改變錯誤,所以兩者共用 revalidate。送出不屬於任何一個欄位,推送時帶 null;而且不論 mode 是哪一種,送出都會驗全部欄位:
const revalidate = (
targets: readonly (keyof V)[],
name: keyof V | null,
): void => {
const errors = nextErrors(state.values, targets);
if (Object.is(errors, state.errors)) return;
state = { ...state, errors };
push(name);
};
const blur = (name: keyof V): void => {
if (validation?.mode === "onBlur") revalidate([name], name);
};
const submit = (): void => revalidate(names, null);
這裡的 names 是 Object.keys(defaultValues)。這份 control 跟前兩天一樣,是為了觀察通知而寫的最小版本,並不是 production-ready 的實作。
💡 真正的表單函式庫在驗證上還有很多這裡沒做的事,例如非同步驗證、schema 驗證、送出之後改用另一種時機重新驗證(RHF 的
reValidateMode),以及 RHF 另外兩種時機onTouched與all。它們回答的是「怎麼驗」,這裡只保留「什麼時候驗」。
表單這一邊,Email 欄在離開時呼叫 control.blur,表單送出時呼叫 control.submit;錯誤訊息則用昨天照 RHF 臨摹的 useFormState,讀出 errors.email。為了能切換三種時機,最外層放三顆按鈕,每換一種時機,就重新建立一份表單:
import { useState } from "react";
import {
createFormControl,
type FormControl,
type ValidationMode,
} from "./form-control";
import { useFormState } from "./use-form-state";
const defaultValues = { name: "", email: "", message: "" };
type Values = typeof defaultValues;
const validate = (name: keyof Values, values: Values): string | null =>
name === "email" && !values.email.includes("@") ? "Email 要有 @" : null;
const EmailError = ({ control }: { control: FormControl<Values> }) => {
const formState = useFormState(control);
console.log("錯誤訊息 渲染");
return <p role="alert">{formState.errors.email}</p>;
};
const SignupForm = ({ mode }: { mode: ValidationMode }) => {
const [control] = useState(() =>
createFormControl(defaultValues, { mode, validate }),
);
return (
<form
onSubmit={(event) => {
event.preventDefault();
control.submit();
}}
>
<label>
Email
<input
defaultValue={defaultValues.email}
onChange={(event) => control.setValue("email", event.target.value)}
onBlur={() => control.blur("email")}
/>
</label>
<EmailError control={control} />
<button type="submit">送出</button>
</form>
);
};
const modes: ValidationMode[] = ["onChange", "onBlur", "onSubmit"];
export const App = () => {
const [mode, setMode] = useState<ValidationMode>("onChange");
return (
<>
{modes.map((m) => (
<button key={m} type="button" onClick={() => setMode(m)}>
{m}
</button>
))}
<SignupForm key={mode} mode={mode} />
</>
);
};
掛載時,主控台先印出一行「錯誤訊息 渲染」。接著選 onChange,在 Email 欄打下 l,主控台再印出一行:
錯誤訊息 渲染
畫面上多了一行「Email 要有 @」。
測試裡的那一棵樹,還多了姓名欄、留言欄與送出按鈕。pnpm test 印出的「在 Email 欄打下第一個字(空白 → l)」那一步,三種時機並排是這樣(讀取時記下的版本):
| 元件 | onChange | onBlur | onSubmit |
|---|---|---|---|
| 表單本體 | — | — | — |
| 姓名欄 | — | — | — |
| Email 欄 | — | — | — |
| 留言欄 | — | — | — |
| Email 錯誤訊息(讀 errors.email) | 通知 | — | — |
| 送出按鈕(讀 isDirty) | 通知 | 通知 | 通知 |
送出按鈕那一列,昨天已經看過:第一個字讓 isDirty 跨過那條線。不同的是錯誤訊息那一列,只有 onChange 通知了它。
使用者才打了一個字,畫面就說這個 Email 錯了。可是使用者根本還沒有機會打出 @。
這一格的「通知」,跟上一張表的那些不一樣。它不是一次畫面上看不出差別的重新計算,而是使用者眼前多出來的一行字。
遠端資料通常要等一個請求回來才變一次;共享狀態那幾天,多半要等使用者按下一個按鈕。表單不一樣,使用者每打一個字,狀態就變一次,而那一刻,使用者的視線就停在這個欄位上。
當更新頻率高到每次輸入都可能觸發通知,「誰該知道」就從效能優化變成了互動品質。
接著打下 e,值從 l 變成 le。在 onChange 下,這一下擊鍵又驗了一次 Email,主控台卻沒有新的輸出。pnpm test 印出的「再打一個字(l → le)」那一步,整張表都是「—」:
| 元件 | onChange | onBlur | onSubmit |
|---|---|---|---|
| 表單本體 | — | — | — |
| 姓名欄 | — | — | — |
| Email 欄 | — | — | — |
| 留言欄 | — | — | — |
| Email 錯誤訊息(讀 errors.email) | — | — | — |
| 送出按鈕(讀 isDirty) | — | — | — |
明明驗了,為什麼錯誤訊息沒有被通知?
答案要回到剛才先跳過的 nextErrors:
const nextErrors = (
values: V,
targets: readonly (keyof V)[],
): FieldErrors<V> => {
if (validation === null) return state.errors;
let errors = state.errors;
for (const name of targets) {
const error = validation.validate(name, values);
if (!Object.is(errors[name], error)) errors = { ...errors, [name]: error };
}
return errors;
};
它逐一驗過指定的欄位,只有某一格的結果跟上一次不同,才換一份新的 errors;每一格都相同,就交回原本那一份。le 驗出來的仍然是「Email 要有 @」,所以 setValue 換了一份新的 values,errors 卻還是同一份。
推送還是發生了。但昨天的 changedKeys 逐個 key 比較前後兩份狀態,這一次只交回 values。由於錯誤訊息的帳上只有 errors,而 errors 沒有換,所以它沒有被通知。
驗了一次,不代表錯誤變了。
假使 nextErrors 每次驗完都交回一份新的 errors,即使內容一模一樣,錯誤訊息也會在每一下擊鍵被通知一次。畫面上仍是同一行字,但「什麼時候才算變了」的答案,就從「錯誤的內容換了」變成了「又驗了一次」。
所以驗證其實有兩個時間點要決定:什麼時候驗,以及驗完之後,什麼時候才算變了。
le 之後,接著離開 Email 欄,再按下送出,三種時機各走一遍。除了錯誤訊息,其餘各列在三種時機下都相同,所以這裡只把四步裡錯誤訊息那一列排在一起:
| 時機 | 打下 l |
再打 le |
離開 Email 欄 | 按下送出 |
|---|---|---|---|---|
onChange |
通知 | — | — | — |
onBlur |
— | — | 通知 | — |
onSubmit |
— | — | — | 通知 |
三種時機下,錯誤訊息都只被通知一次,差別在哪一步:
onChange:打下第一個字,錯誤就出現了。使用者還在打,畫面已經在說這一欄錯了。onBlur:使用者離開這一欄時,才看到錯誤。onSubmit:使用者一路填完、按下送出,才第一次知道 Email 少了 @。三種時機看起來像是一個設定項。但從這張表來看,它們回答的是同一個問題:使用者還在打字的時候,打到一半的輸入算不算數?
onChange 讓每一下擊鍵都算數;onBlur 要等使用者離開這一欄;onSubmit 則等到使用者表示「我填好了」。太早,使用者還沒打完就看到錯誤;太晚,送出之後才知道要回頭改。
所以這三個時機並不是分成對與錯。它們比較像是在決定:錯誤訊息該在使用者打字的哪一刻被通知。
兩個函式庫都把這個決定交給了呼叫端。RHF 用 useForm 的 mode 選一種,預設是 onSubmit;TanStack Form 則把驗證函式分別掛在 validators 的 onChange、onBlur 與 onSubmit 上。兩邊在送出時,也都會驗全部欄位。
💡 真正的 RHF 在送出那一步,跟上表不太一樣。它的
handleSubmit最後一定會推送一份帶著errors的狀態,而 Day 13 提過,它比對的是 key 的名字,不是值;所以讀過errors的元件,每按一次送出都會重新渲染,錯誤的內容變沒變都一樣。也就是說,在onChange與onBlur下,錯誤訊息在按下送出時也會被通知。臨摹的 control 只在錯誤真的變了才推送,所以不重現這一點;它是我用react-hook-form7.87.0 實際跑過才確認的。
前面幾張表都是「讀取時記下」的版本。換成「事先宣告」,「再打一個字(l → le)」那一步是這樣:
| 元件 | onChange | onBlur | onSubmit |
|---|---|---|---|
| 表單本體 | — | — | — |
| 姓名欄 | — | — | — |
| Email 欄 | 通知 | 通知 | 通知 |
| 留言欄 | — | — | — |
| Email 錯誤訊息(讀 errors.email) | — | — | — |
| 送出按鈕(讀 isDirty) | — | — | — |
錯誤訊息那一列,兩個版本相同;不同的是 Email 欄。
事先宣告的欄位是受控的,它的值要先經過選擇器,才畫得到 <input> 上,所以每一下擊鍵,它都被自己的值通知。讀取時記下的欄位不讀 control,值留在 DOM 裡,所以它沒有被通知。
這跟昨天看到的是同一件事:訂閱關係由讀取產生時,不讀就沒有訂閱;由呼叫端宣告時,要值就得宣告。
所以這幾張表能說的,並不是哪一邊比較好,而是兩種訂閱來源,推導出了不同的通知拓撲。
回到今天的問題。把擊鍵拉成一串之後,大多數的擊鍵都長得像「再打一個字」,表上被通知的欄位與按鈕,重新渲染完畫面上看不出差別。
但錯誤訊息被通知的那一刻,使用者就看到了一行字。什麼時候驗、驗完什麼時候才算變了,決定的是那一行字會在哪一下擊鍵出現。
在這樣的頻率下,「誰該被通知」不只是一筆重新渲染的帳,也是使用者打字時會看到什麼。
我們花了三天決定誰該被通知。那如果根本沒有人需要被通知呢?
本文對照
react-hook-form7.87.0、@tanstack/react-form1.33.5,查核於 2026-08-31;RHF 的mode預設值、handleSubmit,以及@tanstack/form-core1.33.5 的validators,複查於 2026-09-28;實驗跑在react19.3.0 與react-dom19.3.0,查核於 2026-09-28。