iT邦幫忙

2026 iThome 鐵人賽

DAY 13
0
Modern Web

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

【 Day 12 】每次擊鍵,整份表單都得重算一次嗎?|使用者輸入(一)

  • 分享至 

  • xImage
  •  

探索共享狀態的那四天,每一次寫入都是按一下按鈕:加一件、改名字、改內容。一次點擊,一次通知,誰該被通知,我們可以一個一個比對。

但表單不是這樣。使用者在欄位裡打一個字,狀態就變一次;打完一個名字,就是好幾次。

今天進入第三個設計問題:使用者輸入的複雜度 —— 每次擊鍵,誰該被通知?

在討論「誰」之前,先讓我們看看常見的寫法。一份只有三個欄位的表單,每打一個字,會有哪些元件跟著重新渲染?

遠端世界 ✓ · 共享狀態 ✓ · 使用者輸入 ✍️ · 瀏覽器 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 的文章,這個結果應該很眼熟。一包狀態放在最上層,底下的元件各自只讀其中一格,但任何一格變了,所有元件都會重新渲染。Day 09 開頭的 Context API 加上 useState,是同一個形狀。

那麼,這不就是共享狀態的問題再來一次嗎?

形狀確實一樣,差別在次數。Day 09 的那一包狀態,要等使用者按下按鈕才變一次;這一份表單,使用者每打一個字就變一次。而且打字的時候,使用者的視線就停在那個欄位上。

共享狀態的問題,是通知太廣;使用者輸入的問題,是通知太密。

兩個問題的形狀相同,差別在更新的頻率。

通知太廣時,多出來的重新渲染通常是效能上的成本。但通知一旦密到某個程度,重新渲染可能就不再只是效能帳,而會變成使用者看得見的掉字與游標跳動。

所以,每一次擊鍵,該被通知的是整份表單,還是其中的某幾個元件?

Formik 為什麼不是主要的對照組

這種寫法不只出現在手寫的表單裡。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 和留言卻還是跟著重新渲染。

推送帶著欄位名稱,React 卻沒有看

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 比較的結果 重新渲染
姓名 是 變了 是
Email 否 變了 是
留言 否 變了 是

欄位的名稱確實隨著推送送出去了,只是 React 接收推送時,並不會看它。

狀態搬出去了

把狀態從表單本體拿走之後,表單本體不再重新渲染;但打一個字,另外兩個欄位仍然跟著重新渲染。每個欄位聽的,依然是整份狀態。

狀態搬出去了。但 React 怎麼知道哪個欄位該重畫?

本文的實驗跑在 react 19.3.0 與 react-dom 19.3.0,查核於 2026-09-27。


上一篇
【 Day 11 】依賴一定要被描述嗎?|共享狀態(四完)
下一篇
【 Day 13 】訂閱關係,是誰寫下的?|使用者輸入(二)
系列文
再造輪子:30 天臨摹 React Hook 函式庫,探索背後的設計哲學 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言