React Hook Form 高效能表單驗證套件

在 React 中處理表單,傳統的受控元件 (Controlled Components) 方式(即為每個 input 綁定 state)在表單欄位變多時,會導致大量的 re-renders,造成效能問題。

React Hook Form 採取不同的策略,它主要依賴非受控元件 (Uncontrolled Components) 的 ref 機制,加上它獨特的訂閱與隔離重渲染技術,能夠在不影響效能的情況下處理複雜的表單驗證。

安裝

npm install react-hook-form

基本用法 (Basic Usage)

使用 useForm Hook 是起手式。你需要用到三個核心屬性:

  1. register: 這是最核心的函式。它的作用是將 DOM 元素(通常是 <input>)註冊到 Hook Form 中,並回傳必要的屬性(如 onChange, onBlur, name, ref)。
    • 語法{...register("欄位名稱", { 驗證規則 })}
    • 用法:你需要使用 Spread Operator (...) 將其展開到 input 元素上。
  2. handleSubmit: 這個函式用來處理表單提交。它接收兩個參數:
    • onSubmit (必填):當驗證通過時執行的 callback,參數會是表單的資料物件。
    • onError (選填):當驗證失敗時執行的 callback。
    • 用法:放在 <form>onSubmit 屬性中,例如:<form onSubmit={handleSubmit(onSubmit)}>
  3. formState: 這是一個物件,包含了表單當前的所有狀態資訊。
    • 常見屬性errors (錯誤訊息), isDirty (是否被修改過), isSubmitting (是否正在提交), isValid (是否通過驗證)。
    • 注意:React Hook Form 使用 Proxy 來優化效能,只有你有「讀取」到的屬性改變時,才會觸發重新渲染。
import { useForm } from 'react-hook-form';

export default function App() {
  const {
    register,
    handleSubmit,
    watch,
    formState: { errors },
  } = useForm();

  const onSubmit = (data) => console.log(data);

  // 監聽特定欄位(如果需要在渲染時顯示即時值)
  console.log(watch('example'));

  return (
    /* "handleSubmit" 會在呼叫 "onSubmit" 之前驗證你的輸入 */
    <form onSubmit={handleSubmit(onSubmit)}>
      {/* 註冊 input:register('欄位名稱') */}
      <input defaultValue="test" {...register('example')} />

      {/* 加上驗證規則:required: true */}
      <input {...register('exampleRequired', { required: true })} />

      {/* 顯示錯誤訊息 */}
      {errors.exampleRequired && <span>此欄位必填</span>}

      <input type="submit" />
    </form>
  );
}

驗證規則 (Validation)

register 的第二個參數可以用來定義驗證規則。支援標準的 HTML 驗證屬性:

  • required
  • min / max
  • minLength / maxLength
  • pattern
  • validate (自訂驗證函式)
<input
  {...register('firstName', {
    required: '請輸入名字', // 可以直接是錯誤字串
    maxLength: {
      value: 20,
      message: '名字不能超過 20 個字元',
    },
    pattern: {
      value: /^[A-Za-z]+$/i,
      message: '只能輸入字母',
    },
  })}
/>;
{
  /* 顯示定義好的錯誤訊息 */
}
<p>{errors.firstName?.message}</p>;

整合 UI Library

如果你使用 Material UI、Ant Design 等第三方 UI 庫,它們的 input 可能不容易直接使用 ...register(因為 ref 的轉發問題)。這時可以使用 Controller 元件。

Controller 常用屬性

Controller 是一個 Wrapper 元件,幫你處理了註冊和與外部 UI 庫的溝通。

  • name (必填):欄位的唯一名稱,用來當作輸出的 key。
  • control (必填):從 useForm 回傳的 control 物件。
  • render (必填):用來渲染 UI 元件的 render prop。它會接收 fieldfieldState
    • field: 包含 onChange, onBlur, value, ref,你需要把這些屬性傳遞給你的 UI 元件。
    • fieldState: 包含 invalid, isTouched, isDirty, error,用來顯示錯誤狀態。
  • rules (選填):驗證規則,格式與 register 的第二個參數相同。
import { useForm, Controller } from 'react-hook-form';
import TextField from '@mui/material/TextField';

function MyForm() {
  const { control, handleSubmit } = useForm({
    defaultValues: {
      firstName: '',
    },
  });

  return (
    <form onSubmit={handleSubmit((data) => console.log(data))}>
      <Controller
        name="firstName"
        control={control}
        rules={{ required: true }}
        render={({ field }) => <TextField {...field} label="First Name" variant="outlined" />}
      />
      <input type="submit" />
    </form>
  );
}

Schema Validation (搭配 Zod)

雖然 React Hook Form 內建基本驗證,但對於複雜的資料結構驗證,搭配 Schema Library(如 Zod 或 Yup)是最佳實踐。這需要安裝 @hookform/resolvers

npm install zod @hookform/resolvers

Zod 常用語法

Zod 是一個以 TypeScript 為優先的 Schema 宣告與驗證庫。它的語法非常直觀,幾乎就像是在寫 TypeScript 的 interface。

  • 基礎型別z.string(), z.number(), z.boolean(), z.date()
  • 常用驗證
    • .min(5, "錯誤訊息") / .max(10)
    • .email("Email 格式錯誤")
    • .url()
    • .optional() (設為非必填)
    • .nullable() (允許 null)
  • 物件與推斷
    • z.object({ ... }): 定義物件結構
    • z.infer<typeof schema>: 自動從 schema 推斷出 TypeScript 型別
import { z } from 'zod';

// 定義 Schema
const UserSchema = z.object({
  username: z.string().min(3, '使用者名稱至少需 3 個字元'),
  email: z.string().email('請輸入有效的 Email'),
  age: z.number().min(18).optional(), // 選填
  website: z.string().url().nullable(), // 可以是 null
});

// 自動推斷 TypeScript 型別
type User = z.infer<typeof UserSchema>;

應用範例:

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';

// 1. 定義 Schema
const schema = z.object({
  username: z.string().min(1, { message: '必填' }),
  age: z.number().min(18, { message: '必須年滿 18 歲' }),
  email: z.string().email({ message: 'Email 格式錯誤' }),
});

export default function schemaForm() {
  // 2. 將 resolver 傳入 useForm
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm({
    resolver: zodResolver(schema),
  });

  return (
    <form onSubmit={handleSubmit((d) => console.log(d))}>
      <input {...register('username')} />
      <p>{errors.username?.message}</p>

      {/* 注意:input type="number" 預設也是字串,需使用 valueAsNumber */}
      <input type="number" {...register('age', { valueAsNumber: true })} />
      <p>{errors.age?.message}</p>

      <input {...register('email')} />
      <p>{errors.email?.message}</p>

      <input type="submit" />
    </form>
  );
}

監聽數值 (Watching Values)

watch 的作用就像是為表單欄位裝上了監視器。當你想要根據使用者的輸入即時改變 UI(例如:勾選 "顯示更多" 才出現額外欄位,或是即時預覽輸入內容)時,就需要使用它。

它跟 getValues() 的最大差別在於:watch 會訂閱輸入變化並觸發元件重新渲染 (Re-render),而 getValues() 只會默默地讀取當前值,不會影響 UI。

// 監聽單一欄位
const watchShowAge = watch('showAge');

// 監聽多個欄位
const watchAllFields = watch(['firstName', 'lastName']);

// 監聽所有欄位
const watchAll = watch();

return (
  <>
    <input type="checkbox" {...register('showAge')} />
    {/* 根據 showAge 的值動態顯示 */}
    {watchShowAge && <input type="number" {...register('age')} />}
  </>
);

手動操作 (Manual Operations)

有時候我們需要手動設定或讀取表單值,而不是透過 input onChange。

  • setValue: 設定欄位值。
  • getValues: 讀取欄位值 (不會觸發 re-render)。
  • reset: 重置表單值。
const { setValue, getValues, reset } = useForm();

// 設定值 (shouldValidate: true 代表設定後立即觸發驗證)
setValue('firstName', 'Mike', { shouldValidate: true });

// 讀取值
const values = getValues(); // { firstName: 'Mike', ... }

// 重置表單
reset({ firstName: 'New Name' });

表單狀態 (Form State)

formState 物件包含了表單的各種狀態資訊,這對於 UX 非常重要(例如在提交中禁用按鈕)。

const {
  formState: {
    isDirty, // 使用者是否修改過任何欄位
    isValid, // 表單是否通過所有驗證
    isSubmitting, // 是否正在提交中 (handleSubmit 的 callback 執行期間)
    isSubmitSuccessful, // 是否提交成功
    submitCount, // 提交次數
  },
} = useForm({ mode: 'onChange' }); // mode: 'onChange' 會讓 isValid 即時更新

return (
  <button disabled={!isDirty || !isValid || isSubmitting}>
    {isSubmitting ? '提交中...' : '送出'}
  </button>
);

動態表單 (Dynamic Arrays)

對於陣列型態的欄位(例如:新增多個聯絡人),使用 useFieldArray。它提供了 append, prepend, remove, swap, move 等方法。

import { useForm, useFieldArray } from 'react-hook-form';

function App() {
  const { register, control, handleSubmit } = useForm({
    defaultValues: {
      test: [{ name: 'test', quantity: 1 }],
    },
  });

  const { fields, append, remove } = useFieldArray({
    control,
    name: 'test',
  });

  return (
    <form onSubmit={handleSubmit((data) => console.log(data))}>
      <ul>
        {fields.map((item, index) => (
          <li key={item.id}>
            {/* 重要:使用 index 來註冊 */}
            <input {...register(`test.${index}.name`)} />
            <input type="number" {...register(`test.${index}.quantity`)} />
            <button type="button" onClick={() => remove(index)}>
              Delete
            </button>
          </li>
        ))}
      </ul>
      <button type="button" onClick={() => append({ name: 'appendBill', quantity: 2 })}>
        Append
      </button>
      <input type="submit" />
    </form>
  );
}

總結

React Hook Form 提供了更好的效能和更簡潔的 API,特別是當你需要處理大量欄位或是需要與後端驗證邏輯(透過 Zod)共用 Schema 時,它是一個非常強大的選擇。