iT邦幫忙

2026 iThome 鐵人賽

DAY 14
0
Modern Web

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

【 Day 13 】訂閱關係,是誰寫下的?|使用者輸入(二)

  • 分享至 

  • xImage
  •  

昨天我們把表單的狀態從 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 的寫法。這裡用的是照著 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 是怎麼知道的?

記下讀了哪個 key

答案在 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。

推送進來時,比對 key 的名字

換句話說,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 的作法

再看 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 的版本,沒有新的輸出。
  • TanStack Form 的版本,印出一行「狀態 渲染」。

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-form 7.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-form 7.87.0、@tanstack/react-form 1.33.5 為基礎,於 2026-08-31 檢閱;RHF 的 getProxyFormState 與 useFormState、TanStack Form 依賴的 @tanstack/react-store 0.11.1 的 useSelector,以及 TanStack Form 的 philosophy 頁複查於 2026-09-28;實驗跑在 react 19.3.0 與 react-dom 19.3.0,查核於 2026-09-28。


上一篇
【 Day 12 】每次擊鍵,整份表單都得重算一次嗎?|使用者輸入(一)
下一篇
【 Day 14 】高頻之下,「誰該被通知」還是效能問題嗎?|使用者輸入(三)
系列文
再造輪子:30 天臨摹 React Hook 函式庫,探索背後的設計哲學 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言