探索共享狀態的那四天,每一次寫入都是按一下按鈕:加一件、改名字、改內容。一次點擊,一次通知,誰該被通知,我們可以一個一個比對。
但表單不是這樣。使用者在欄位裡打一個字,狀態就變一次;打完一個名字,就是好幾次。
今天進入第三個設計問題:使用者輸入的複雜度 —— 每次擊鍵,誰該被通知?
在討論「誰」之前,先讓我們看看常見的寫法。一份只有三個欄位的表單,每打一個字,會有哪些元件跟著重新渲染?
遠端世界 ✓ · 共享狀態 ✓ · 使用者輸入 ✍️ · 瀏覽器 API · 互動行為 · 真實 DOM
寫一份表單,最直接的作法,是把所有欄位的值放進一個物件,透過 useState 來管理,然後由表單元件往下傳給每個欄位。為了看出每次擊鍵有誰重新渲染,我們在表單與欄位裡各放一行 console.log:
import { useState } from "react";
const defaultValues = { name: "", email: "", message: "" };
type Values = typeof defaultValues;
interface FieldProps {
label: string;
value: string;
onChange: (value: string) => void;
}
const Field = ({ label, value, onChange }: FieldProps) => {
console.log(`${label} 渲染`);
return (
<label>
{label}
<input value={value} onChange={(event) => onChange(event.target.value)} />
</label>
);
};
export const SignupForm = () => {
const [values, setValues] = useState(defaultValues);
console.log("表單 渲染");
const update = (key: keyof Values) => (value: string) =>
setValues((prev) => ({ ...prev, [key]: value }));
return (
<form>
<Field label="姓名" value={values.name} onChange={update("name")} />
<Field label="Email" value={values.email} onChange={update("email")} />
<Field label="留言" value={values.message} onChange={update("message")} />
</form>
);
};
掛載時,主控台印出四行,每個元件各一次,這符合預期。
接著,在「姓名」欄打下一個 L,主控台印出:
表單 渲染
姓名 渲染
Email 渲染
留言 渲染
再打一個 e,同樣的四行又出現一次。
Email 與留言的值從頭到尾都是空的,它們為什麼也重新渲染了?
因為這份狀態是 SignupForm 的。setValues 讓表單本體重新渲染,而 React 重新渲染一個元件時,預設會連它底下的子元件一起重新渲染。
React 的重新渲染,不是直接重畫 DOM,而是重新執行元件函式,重新計算這次應該長什麼樣子。
所以只要有一個欄位變了,整份表單就從頭算一次。
這裡只有三個欄位,可能感覺不到明顯影響。
不過問題並非表單規模大小,真正的問題核心在於,一個欄位的一次擊鍵,就足以讓整份表單重新算一次。
💡 用
memo包住Field,上面的輸出並不會改變,因為update("email")每次渲染都會交回一個新的函式。要讓 Email 與留言停下來,還得替每個欄位準備身分不變的onChange(例如用useCallback);即使如此,每一次擊鍵,SignupForm本身仍然會重新渲染。
如果你有讀過 Day 09 的文章,這個結果應該很眼熟。一包狀態放在最上層,底下的元件各自只讀其中一格,但任何一格變了,所有元件都會重新渲染。Day 09 開頭的 Context API 加上 useState,是同一個形狀。
那麼,這不就是共享狀態的問題再來一次嗎?
形狀確實一樣,差別在次數。Day 09 的那一包狀態,要等使用者按下按鈕才變一次;這一份表單,使用者每打一個字就變一次。而且打字的時候,使用者的視線就停在那個欄位上。
共享狀態的問題,是通知太廣;使用者輸入的問題,是通知太密。
兩個問題的形狀相同,差別在更新的頻率。
通知太廣時,多出來的重新渲染通常是效能上的成本。但通知一旦密到某個程度,重新渲染可能就不再只是效能帳,而會變成使用者看得見的掉字與游標跳動。
所以,每一次擊鍵,該被通知的是整份表單,還是其中的某幾個元件?
這種寫法不只出現在手寫的表單裡。Formik 把它做成了一個函式庫:整份表單的狀態由最上層的 <Formik> 持有,任何一個欄位變了,它就重新渲染,再透過上下文 (context) 把整包狀態交給底下讀取它的元件。某種程度上,它是今天這份 SignupForm 最完整的版本。
Formik 很重要,因為它代表了「整份表單共享同一包狀態」這條設計路線。它也提供了 <FastField>,讓欄位只在跟自己有關的狀態變了時才重新渲染;但狀態仍然住在 <Formik> 身上,每一次擊鍵,它本身都會重新渲染。
接下來幾天要對照的函式庫,則是一開始就把表單的值放在 React 外面。因此 Formik 不會是主要的對照組。
回到 SignupForm。整份表單之所以會重新渲染,是因為狀態放在表單本體身上。那麼,如果這份狀態不屬於任何一個元件呢?
讓我們仿照 Day 08 寫的 createStore,來寫一個活在 React 外面的 form control。它和 createStore 的差別在推送:每次有欄位被寫入,它在通知聽眾的同時,會一併交出變的是哪一個欄位。先把它對外的樣子寫成型別:
type FormValues = Record<string, string>;
interface FieldChange<V extends FormValues> {
name: keyof V;
}
export interface FormControl<V extends FormValues> {
getState(): { values: V };
setValue(name: keyof V, value: string): void;
subscribe(listener: (change: FieldChange<V>) => void): () => void;
}
接著是實作。跟 createStore 一樣,每一次寫入都會換一份新的狀態;差別只在通知聽眾的那一行,多交出了 { name }:
export function createFormControl<V extends FormValues>(
defaultValues: V,
): FormControl<V> {
let state = { values: defaultValues };
const listeners = new Set<(change: FieldChange<V>) => void>();
const getState = () => state;
const setValue = (name: keyof V, value: string): void => {
if (Object.is(state.values[name], value)) return;
state = { values: { ...state.values, [name]: value } };
for (const listener of [...listeners]) listener({ name });
};
const subscribe = (listener: (change: FieldChange<V>) => void) => {
listeners.add(listener);
return (): void => {
listeners.delete(listener);
};
};
return { getState, setValue, subscribe };
}
接著改寫表單。表單本體只負責建立一次 control,不再持有任何值;每個欄位則像 Day 08 那樣,自己用 useSyncExternalStore 接上它:
import { useState, useSyncExternalStore } from "react";
import { createFormControl, type FormControl } from "./form-control";
const defaultValues = { name: "", email: "", message: "" };
type Values = typeof defaultValues;
interface FieldProps {
control: FormControl<Values>;
name: keyof Values;
label: string;
}
const Field = ({ control, name, label }: FieldProps) => {
const state = useSyncExternalStore(
control.subscribe,
control.getState,
control.getState,
);
console.log(`${label} 渲染`);
return (
<label>
{label}
<input
value={state.values[name]}
onChange={(event) => control.setValue(name, event.target.value)}
/>
</label>
);
};
export const SignupForm = () => {
const [control] = useState(() => createFormControl(defaultValues));
console.log("表單 渲染");
return (
<form>
<Field control={control} name="name" label="姓名" />
<Field control={control} name="email" label="Email" />
<Field control={control} name="message" label="留言" />
</form>
);
};
同樣在「姓名」欄打下一個 L,主控台印出:
姓名 渲染
Email 渲染
留言 渲染
看起來有進展。至少表單本體停下來了。
但仔細看輸出,問題其實還在:姓名變了,Email 和留言卻還是跟著重新渲染。
control 在推送時明明說了變的是 name,為什麼另外兩個欄位還是重新渲染了?
先把 React 拿掉,直接看 control 推送了什麼,以及寫入前後的狀態:
const control = createFormControl(defaultValues);
control.subscribe((change) => console.log(`推送:${change.name}`));
const before = control.getState();
control.setValue("name", "L");
const after = control.getState();
console.log(Object.is(before, after));
console.log(Object.is(before.values.email, after.values.email));
主控台印出:
推送:name
false
true
control 給的資訊很具體:變的是 name。而對 Email 欄來說,自己的值前後也確實是同一個。
那麼,React 拿哪一個來判斷?
useSyncExternalStore 交給 subscribe 的那個回呼,不收任何參數,推送裡的 { name } 到這裡就停住了。回呼被呼叫時,React 只會重新呼叫一次 getSnapshot,再用 Object.is 跟上一次的結果比較。
這裡的 getSnapshot 是 control.getState,所以三個欄位問的,都是第二行那個問題:整份狀態換了沒有?答案是換了,於是三個欄位都重新渲染。
| 欄位 | 自己的值變了嗎 | React 比較的結果 | 重新渲染 |
|---|---|---|---|
| 姓名 | 是 | 變了 | 是 |
| 否 | 變了 | 是 | |
| 留言 | 否 | 變了 | 是 |
欄位的名稱確實隨著推送送出去了,只是 React 接收推送時,並不會看它。
把狀態從表單本體拿走之後,表單本體不再重新渲染;但打一個字,另外兩個欄位仍然跟著重新渲染。每個欄位聽的,依然是整份狀態。
狀態搬出去了。但 React 怎麼知道哪個欄位該重畫?
本文的實驗跑在
react19.3.0 與react-dom19.3.0,查核於 2026-09-27。