昨天我們把表單的狀態從 SignupForm 身上搬了出去,放進一個活在 React 外面的 form control。表單本體不再重新渲染,但在姓名欄打一個字,Email 和留言還是跟著重新渲染。
control 推送時明明交出了變的是 name,但每個欄位問的,都是「整份狀態換了沒有」。
也就是說,control 知道「什麼變了」,缺的是另一半:每個元件在乎的是哪一部分。這份對應關係,總得有人寫下來。
那麼,它該由誰來寫?
今天讓我們透過 React Hook Form 與 TanStack Form,看看這個問題的兩種答案。
遠端世界 ✓ · 共享狀態 ✓ · 使用者輸入 ✍️ · 瀏覽器 API · 互動行為 · 真實 DOM
這個問題聽起來有點熟悉。Day 09 的選擇器,是呼叫端親手寫下「我在乎這一塊」;Day 11 的 Valtio,則是元件渲染時讀了哪裡,就記下哪裡。
兩邊都能做到精細訂閱,但記錄的東西不一樣。選擇器記的是算出來的結果;讀取軌跡記的是被讀過的欄位。所以在 Day 11,購物車從 1 變成 2 時,前者停住了,後者沒有。
訂閱的來源不同,比較的單位也跟著不同,兩件事纏在一起。
表單這邊的情況不太一樣。React Hook Form(以下簡稱 RHF)與 TanStack Form 都把表單狀態放在 React 外面:RHF 放在 control 裡,TanStack Form 放在 form.store 裡。兩邊也都讓元件只訂閱自己在乎的那一小塊狀態,而不是整份表單。
狀態放在哪裡、訂閱切得多細,兩邊的答案是一樣的。
剩下的差別,在於這份訂閱關係是誰寫下的。
先替昨天的表單加一顆送出按鈕:表單還沒改過時,按鈕是停用的。為此,control 的狀態多了一個 isDirty,只要現在的值跟預設值不同,它就是 true:
export interface FormState<V extends FormValues> {
readonly values: V;
readonly isDirty: boolean;
}
setValue 每次寫入時,會一併把 isDirty 算好。跟 Day 12 的版本相比,只差在換一份新狀態的那兩行:
const differsFrom = <V extends FormValues>(values: V, defaults: V): boolean =>
Object.keys(defaults).some((key) => !Object.is(values[key], defaults[key]));
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) };
for (const listener of [...listeners]) listener({ name });
};
在姓名欄依序打下 L、再打一個 e、最後刪回空白,值每一步都變了,isDirty 卻只變了兩次:
| 步驟 | 姓名的值 | isDirty |
|---|---|---|
| 打下第一個字 | 空白 → L |
false → true |
| 再打一個字 | L → Le |
true,沒有變 |
| 刪回空白 | Le → 空白 |
true → false |
所以這顆按鈕只需要在第一步與第三步重新渲染。
問題是,control 要怎麼知道這顆按鈕在乎的是 isDirty?
先看 RHF 的寫法。這裡用的是照著 RHF 臨摹的 useFormState,它的呼叫方式跟 RHF 的同名 Hook 一樣,只交出 control,拿回一份 formState。欄位先只留姓名一個,並在送出按鈕裡放一行 console.log:
import { useState } from "react";
import { createFormControl, type FormControl } from "./form-control";
import { useFormState } from "./use-form-state";
const defaultValues = { name: "", email: "", message: "" };
type Values = typeof defaultValues;
const SubmitButton = ({ control }: { control: FormControl<Values> }) => {
const formState = useFormState(control);
console.log("送出按鈕 渲染");
return <button disabled={!formState.isDirty}>送出</button>;
};
export const SignupForm = () => {
const [control] = useState(() => createFormControl(defaultValues));
return (
<form>
<label>
姓名
<input
defaultValue={defaultValues.name}
onChange={(event) => control.setValue("name", event.target.value)}
/>
</label>
<SubmitButton control={control} />
</form>
);
};
掛載時,主控台印出一行「送出按鈕 渲染」。接著在姓名欄打下 L,主控台再印出一行:
送出按鈕 渲染
再打一個 e,主控台沒有新的輸出。刪回空白,又印出一行。
跟預期的一樣,按鈕只在 isDirty 變了的那兩步重新渲染。可是 SubmitButton 從頭到尾沒有交出任何描述,它只是讀了 formState.isDirty。
那麼,useFormState 是怎麼知道的?
答案在 useFormState 交回來的那份 formState。它不是狀態本身,而是一個長得一樣、但每個 key 都是 getter 的物件。元件讀到哪個 key,那個 key 就被記進一個叫 readKeys 的 Set:
export function getProxyFormState<S extends object>(
formState: S,
readKeys: Set<string>,
): S {
const result = {} as S;
for (const key of Object.keys(formState) as (keyof S & string)[]) {
Object.defineProperty(result, key, {
get: () => {
readKeys.add(key);
return formState[key];
},
});
}
return result;
}
Day 11 的 Valtio 用 Proxy 攔下讀取;這裡用的是那一篇順帶提過的另一種作法:Object.defineProperty 替每個 key 各定義一個 getter。它得事先知道有哪些 key,而 formState 的 key 本來就是固定的。
在 RHF 的原始碼裡,記下這筆帳的是 getProxyFormState.ts 裡的這一行:
localProxyFormState && (localProxyFormState[_key] = true);
RHF 的帳是一個旗標物件,讀到哪個 key,就把那個 key 設成 true。
換句話說,RHF 沒有要求元件事先宣告自己訂閱什麼。它把這件事改成另一個問題:這個元件渲染時曾經讀過哪些 key?
元件讀到哪裡,帳就記到哪裡。
帳記好之後,推送進來時要回答的問題是:這一次變了的 key 裡,有沒有一個在帳上?
export function shouldRenderFormState(
changed: readonly string[],
readKeys: ReadonlySet<string>,
): boolean {
return changed.some((key) => readKeys.has(key));
}
這裡比對的是 key 的名字,不是值。某個 key 的值變了沒有,在算出 changed 的時候就已經決定了。
changed 由 changedKeys 算出來,它逐個 key 比較前後兩份狀態,交回換了值的那幾個:
export function changedKeys<S extends object>(prev: S, next: S): string[] {
return (Object.keys(next) as (keyof S & string)[]).filter(
(key) => !Object.is(prev[key], next[key]),
);
}
把這幾塊接上 React,就是 useFormState:
import { useLayoutEffect, useMemo, useState } from "react";
import type { FormControl, FormState, FormValues } from "./form-control";
export function useFormState<V extends FormValues>(
control: FormControl<V>,
): FormState<V> {
const [formState, setFormState] = useState(control.getState);
const [readKeys] = useState(() => new Set<string>());
useLayoutEffect(() => {
let last = control.getState();
return control.subscribe(() => {
const next = control.getState();
const changed = changedKeys(last, next);
last = next;
if (shouldRenderFormState(changed, readKeys)) setFormState(next);
});
}, [control, readKeys]);
return useMemo(
() => getProxyFormState(formState, readKeys),
[formState, readKeys],
);
}
打下 L 時,changedKeys 交回 values 與 isDirty;再打一個 e 時,只交回 values。送出按鈕的帳上只有 isDirty,所以第二步沒有落在帳上,按鈕也就沒有重新渲染。
RHF 本身不在這裡比。它的 control 在寫入時算好 isDirty 這類值,只有被追蹤的 key 真的變了,才推送一份帶著那些 key 的物件。我們的 control 推送時只帶欄位名稱,所以由這一層自己比一次。
💡 這個 Hook 沒有用
useSyncExternalStore,而是照 RHF 的useFormState:用一份useState存著上一次畫出來的狀態,在 layout effect 裡向 control 訂閱,推送進來時自己決定要不要更新。
再看 TanStack Form。同樣是一顆讀 isDirty 的送出按鈕,這次改用照 TanStack Form 臨摹的 useSelector:
import { useSelector } from "./use-selector";
const SubmitButton = ({ control }: { control: FormControl<Values> }) => {
const isDirty = useSelector(control, (state) => state.isDirty);
console.log("送出按鈕 渲染");
return <button disabled={!isDirty}>送出</button>;
};
把 SignupForm 裡的 SubmitButton 換成這一版,重複同樣三步,主控台印出的東西跟 RHF 那一版一模一樣:第一步與第三步各一行,第二步沒有。
差別在呼叫端。這一版的按鈕,親手寫下了「我在乎的是 isDirty」。
useSelector 的本體,跟 Day 09 的 useSlice 是同一行 useSyncExternalStore:
import { useMemo, useSyncExternalStore } from "react";
import type { FormControl, FormState, FormValues } from "./form-control";
export function useSelector<V extends FormValues, T>(
control: FormControl<V>,
selector: (state: FormState<V>) => T,
compare: (a: T, b: T) => boolean = Object.is,
): T {
const getSelection = useMemo(() => {
let last: { state: FormState<V>; selected: T } | null = null;
return (): T => {
const state = control.getState();
if (last !== null && Object.is(last.state, state)) return last.selected;
const selected = selector(state);
last = {
state,
selected:
last !== null && compare(last.selected, selected)
? last.selected
: selected,
};
return last.selected;
};
}, [control, selector, compare]);
return useSyncExternalStore(control.subscribe, getSelection, getSelection);
}
每次推送進來,React 呼叫 getSelection,它跑一次選擇器,再拿結果跟上一次比。一樣的話,就交回上一次那一份,React 看到同一個身分,便不重新渲染。
跟 useSlice 不同的地方,在第三個引數 compare。Day 09 的 useSlice 只收兩個引數,「什麼算變了」永遠是 React 的那一次 Object.is;這裡則讓呼叫端可以換掉它。
在 TanStack Form 的原始碼裡,form.Subscribe 元件訂閱表單狀態的,是 useForm.tsx 裡的這一行:
const data = useSelector(form.store, selector);
它接的 useSelector 來自 @tanstack/react-store,形狀跟上面這一份相同:一個 store、一個選擇器,以及一個可以換掉的比較函式。
到這裡,兩個版本的送出按鈕行為相同,差別只在呼叫端有沒有寫下選擇器。
那麼,這份關係寫在哪裡,真的有差別嗎?
讓我們換一個元件。它只在「詳細模式」下才顯示表單有沒有改過,其餘時候只顯示一條橫線。先看 RHF 的版本:
const Status = ({
control,
verbose,
}: {
control: FormControl<Values>;
verbose: boolean;
}) => {
const formState = useFormState(control);
console.log("狀態 渲染");
if (!verbose) return <p>—</p>;
return <p>{formState.isDirty ? "改過了" : "還沒改"}</p>;
};
TanStack Form 的版本,只差在第一行:
const isDirty = useSelector(control, (state) => state.isDirty);
把 SignupForm 裡的 SubmitButton 換成 Status,並加一顆切換詳細模式的按鈕:
export const SignupForm = () => {
const [control] = useState(() => createFormControl(defaultValues));
const [verbose, setVerbose] = useState(false);
return (
<form>
<label>
姓名
<input
defaultValue={defaultValues.name}
onChange={(event) => control.setValue("name", event.target.value)}
/>
</label>
<Status control={control} verbose={verbose} />
<button type="button" onClick={() => setVerbose((v) => !v)}>
詳細模式
</button>
</form>
);
};
先不切換,直接在姓名欄打下 L。這時兩個版本的畫面上都只有一條橫線,主控台卻不一樣:
RHF 的 Status 這次渲染沒有讀 isDirty,帳上就沒有它;TanStack Form 的 Status 寫下了選擇器,不論畫面用不用得到,它都訂閱著 isDirty。
到這裡,看起來 RHF 的版本比較省。
接著重新整理頁面,這次先按兩下「詳細模式」,切進去再切回來,然後才在姓名欄打下 L。畫面上仍然只有一條橫線,但這一次,兩個版本都印出了一行「狀態 渲染」。
TanStack Form 的版本跟剛才一樣。
RHF 的版本卻變了:切進詳細模式的那一次渲染讀了 isDirty,它就被記進帳裡;切回來之後,雖然不再讀它,帳上的那一筆也沒有被拿掉。
readKeys 只會變長,不會變短。
這裡有趣的不是重新渲染本身。有趣的是,兩次情境裡畫面相同、程式碼相同,差別只在這個元件過去曾不曾經看過 isDirty。
在 RHF 裡,訂閱關係不完全寫在程式碼裡,也存在於元件過去的渲染歷史裡。
把兩次的結果放在一起:
| 情境 | RHF 的版本 | TanStack Form 的版本 |
|---|---|---|
從沒讀過 isDirty,打下 L |
不重新渲染 | 重新渲染 |
讀過一次 isDirty 之後不再讀,打下 L |
重新渲染 | 重新渲染 |
兩個版本在兩種情境下,畫面都是同一條橫線。RHF 的版本前後表現不同,但它的程式碼一個字都沒有改,改的只是這個元件過去讀過什麼。
TanStack Form 的訂閱,寫在選擇器那一行,打開檔案就看得到;RHF 的訂閱,則是元件渲染時讀過的 key 累積起來的結果,打開檔案看不到。
💡 真正的 RHF 在這裡還多一層。沒有任何元件讀過
isDirty之前,control 在寫入時不會去更新它,所以先打字、之後才第一次讀,讀到的是false。RHF 的帳不只決定通知誰,也決定 control 要算什麼。臨摹裡的 control 每次寫入都會算isDirty,所以不重現這個現象;它是我用react-hook-form7.87.0 實際跑過才確認的。
如果把這條線再往下追,最後會碰到欄位本身怎麼取得值。
也就是 React 文件裡常說的受控 (controlled) 與非受控 (uncontrolled)。
今天兩個版本的姓名欄,都用 defaultValue 交出初始值,之後的值由 <input> 自己保管,這是非受控的寫法。
RHF 本身也是這樣。register 交給 <input> 的主要是 name、ref、onChange 與 onBlur,沒有 value;值由 <input> 自己保管,RHF 透過 onChange 與 ref 取得它。欄位不必讀 formState,在「讀了就算」的規則下,也就不必訂閱什麼。
TanStack Form 則相反。它的欄位要畫出自己的值,得先用選擇器把它選出來:useField 會透過 useSelector,從欄位自己的 store 選出它的值。值從 store 來,再交給 <input>,這是受控的寫法。TanStack Form 的 philosophy 也明白寫著它刻意選擇受控,理由包括表單狀態隨時可以預測、容易測試,以及能用在 React Native 這類不是 DOM 的渲染環境。
所以我目前的理解是,受控與非受控不是兩邊分歧的起點。起點在訂閱關係由誰寫下:由讀取產生時,欄位不讀就不必訂閱,值可以留在 DOM;由呼叫端宣告時,欄位要值就得宣告,值也就跟著 store 走。
回到今天的送出按鈕。兩種寫法都讓它只在 isDirty 變了的那兩步重新渲染,差別在那份關係寫在哪裡:TanStack Form 要求呼叫端把關係寫出來;RHF 則把關係藏在讀取過程裡。
真正的差別不是誰比較快,也不是誰少渲染一次,而是這份訂閱關係由誰維護。
不過今天的實驗裡,姓名欄只打了兩個字。換成一個人一秒打十幾個字,每按一次鍵,被叫醒的又是哪些元件?
本文以
react-hook-form7.87.0、@tanstack/react-form1.33.5 為基礎,於 2026-08-31 檢閱;RHF 的getProxyFormState與useFormState、TanStack Form 依賴的@tanstack/react-store0.11.1 的useSelector,以及 TanStack Form 的 philosophy 頁複查於 2026-09-28;實驗跑在react19.3.0 與react-dom19.3.0,查核於 2026-09-28。