概述
各位未來前端棟樑你們好,歡迎來到 「React 專案表單套件篇」,此篇在介紹當遇到中大型表單或者覺得表單狀態難以掌控時我們可以使用更方便的套件幫忙管理。 本篇章會使用目前常見的兩種套件做搭配使用( 如標題 ),但我們先來釐清一起這兩者套件的關係 :
兩者套件都能獨立做使用,而 RHF 提供第三方的 Schema Validation 套件進行組合使用,這樣就能做出更彈性的表單驗證自訂邏輯。
React-Hook-Form (RHF): 基於 React Hook 所打造的表單狀態管理,可進行統一管理、提供不同狀態及設置方式,並且可優化 React 表單狀態下在re-render問題。Zod: Zod 是以 TypeScript 型別系統為核心的 schema 驗證函式庫,可簡單定義資料結構,並在執行期間進行型別安全的驗證。以下用範例來簡述 :
// 單純使用 TypeScript 撰寫表單驗證
type User = {
name: string;
age: number;
};
/**
* 處理 表單驗證
* @description 可以注意到我們這邊還要額外驗證型別會相對麻煩
**/
function validate(data) {
if (typeof data.name !== 'string') throw new Error();
if (typeof data.age !== 'number') throw new Error();
}
// 透過 zod 定義好 Schema
const userSchema = z.object({
name: z.string(),
age: z.number(),
});
// 型別從 schema 推導,不用另外宣告
type User = z.infer<typeof userSchema>;RHF + Zod 流程介紹(同時使用)
這邊就直接不贅述它們各自的使用方式(有興趣的話下方章節有簡單介紹)。
使用者送出表單
-> handleSubmit 觸發
-> RHF 把表單值丟給 resolver
-> resolver 呼叫 schema.parse() / schema.validate()
-> 驗證通過 → onSubmit(data)
-> 驗證失敗 → 錯誤寫回 formState.errorsresolver:可以當成 RHF 用來解析外部驗證器的 middleware,將表單驗證結果轉換成符合 RHF 格式的表單值。
補充小知識:早期 RHF 在實作第三方的
resolver時是針對各自套件所客製化撰寫的,但近年來這種不便性也隨之而來,所以套件方的作者就一起討論出了一種 Type 標準,稱之「Standard-schema」,只需要由第三方的套件去複製這 Type 檔就可以讓 TypeScript 系統成功推論(而該檔案的程式碼行數也非常之少呢!)我有興趣看「Standard-schema」原始碼 以下就來看看怎麼結合使用吧!
import zod from 'zod';
import { useForm } from 'react-hook-form';
import { standardSchemaResolver } from '@hookform/resolvers/standard-schema';
const schema = z.object({
name: z.string().min(1, '姓名為必填'), // 長度最少為 1,錯誤訊息為 "姓名為必填"
email: z.email('請輸入有效的 Email'),
age: z.number({ error: '請輸入年齡' }).int().min(18, '須年滿 18 歲'),
});
const {
register,
handleSubmit, // 需要將我們提交的程式碼交由 RHF 代管,所以可以看到下方 onSubmit 被包覆
formState: { errors }, // 當發生不符合 schema 所驗證的規則時,錯誤訊息就會被寫入 formState.errors
} = useForm({
resolver: standardSchemaResolver(schema), // 上面我們有提到的 resolver 作用
// defaultValues 會被 values 值蓋過,可以想像這就是表單 state 值控管的地方
defaultValues: {
name: '',
email: '',
},
values: {
name: '我是超級大蟀哥',
email: 'Wo_Shi_Chao_ji_da_shuai_ge@gmail.com',
},
});
/**
* 處理 表單提交
*/
const onSubmit = (data) => {
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<label>姓名</label>
<input {...register('name')} />
{errors.name && <p>{errors.name.message}</p>}
</div>
<div>
<label>Email</label>
<input type="email" {...register('email')} />
{errors.email && <p>{errors.email.message}</p>}
</div>
{/* valueAsNumber 會在表單驗證前將值轉換成 Number
如果不這樣做的話,input 輸入時為 string,schema 驗證永遠不會通過 */}
<div>
<label>年齡</label>
<input type="number" {...register('age', { valueAsNumber: true })} />
{errors.age && <p>{errors.age.message}</p>}
</div>
<button type="submit">送出</button>
</form>
);Zod v4
Zod 是以 TypeScript 為核心的 schema 驗證函式庫,讓你用簡潔的 API 定義資料結構,並在執行期間進行型別安全的驗證,使用方式建議直接看官方文件會更明白( 絕不是因為偷懶 )。點我查看
import zod from 'zod';
const userSchema = zod.object({
name: zod.string().min(1, '姓名為必填'),
age: zod.number().int().min(0),
email: zod.email(),
});Zod 常見問題
1. 我想統一自訂錯誤訊息該怎麼辦?
很好,你很細心的想要極致客製化錯誤訊息,不愧是未來前端棟樑,官方提供兩種做法。
- 透過官方的
Locales翻譯檔案直接偷懶
import * as z from 'zod';
import { zhTW } from 'zod/locales';
z.config(zhTW()); // 搞定,沒錯 很簡單- 使用自訂方法判定錯誤訊息 ( 你應該會選這方式...應該啦 )
/**
* 處理 自訂錯誤訊息
*/
const errorMap = (issue) => {
switch (issue.code) {
case 'invalid_type':
if (issue.received === 'undefined' || issue.received === 'null') {
return { message: '此欄位為必填' };
}
if (issue.expected === 'number') {
return { message: '請輸入數字' };
}
if (issue.expected === 'string') {
return { message: '請輸入文字' };
}
break;
case 'too_small':
if (issue.type === 'string') {
return {
message:
issue.minimum === 1
? '此欄位為必填'
: `至少需要 ${issue.minimum} 個字`,
};
}
if (issue.type === 'number') {
return { message: `不能小於 ${issue.minimum}` };
}
if (issue.type === 'array') {
return { message: `至少選 ${issue.minimum} 項` };
}
break;
case 'too_big':
if (issue.type === 'string') {
return { message: `最多 ${issue.maximum} 個字` };
}
if (issue.type === 'number') {
return { message: `不能大於 ${issue.maximum}` };
}
break;
case 'invalid_string':
if (issue.validation === 'email') {
return { message: 'Email 格式不正確' };
}
if (issue.validation === 'url') {
return { message: '網址格式不正確' };
}
if (issue.validation === 'regex') {
return { message: '格式不正確' };
}
break;
case 'invalid_enum_value':
return { message: '請從選項中選擇' };
}
return { message: '欄位錯誤' };
};
z.config({ customError: errorMap }); // 也太多了吧 沒錯就是這麼多React Hook Form v7
- 以
useFormhook 為入口,管理表單狀態、驗證、錯誤 - 提供
register、Controller、useFieldArray等工具應對各種場景 - 支援外部 schema 驗證函式庫,透過 resolver 接入
import { useForm } from 'react-hook-form';
const {
register,
handleSubmit,
formState: { errors },
} = useForm();useForm 參數說明(常用)
| 參數 | 預設值 | 說明 |
|---|---|---|
mode | onSubmit | 驗證觸發時機(送出前)。onChange 每次輸入觸發、onBlur 離開欄位觸發、onSubmit 送出時觸發、onTouched 第一次 blur 後觸發、all 全部 |
reValidateMode | onChange | 驗證觸發時機(送出後,有錯誤的欄位重新驗證時機)。可選 onChange、onBlur、onSubmit |
defaultValues | {} | 表單初始值,支援同步或非同步(async function)。會被 cache,重置要用 reset() |
values | — | 從外部狀態或 server 動態更新表單值,每次變動都會同步進表單 |
resolver | undefined | 接入外部 schema 驗證(Zod、Yup 等),透過 @hookform/resolvers 提供 |
criteriaMode | firstError | 錯誤收集模式。firstError 只回傳第一個錯誤、all 回傳所有錯誤(存在 errors.field.types) |
shouldFocusError | true | 送出驗證失敗時,是否自動 focus 到第一個有錯誤的欄位 |
shouldUnregister | false | 元件 unmount 時是否自動移除該欄位的值與驗證。true 行為接近原生表單 |
delayError | undefined | 錯誤訊息延遲顯示的毫秒數,避免輸入中途就顯示錯誤 |
useForm 回傳值說明(常用)
| 回傳值 | 說明 |
|---|---|
register | 註冊欄位,回傳 ref、onChange、onBlur、name 供 input 使用,可附加驗證規則 |
unregister | 取消註冊欄位,移除對應的值與驗證 |
handleSubmit | 包裝 submit handler,驗證通過才呼叫 callback,失敗則將錯誤寫入 formState.errors |
watch | 訂閱欄位值變動,每次變動都會 re-render,適合用於條件渲染 |
getValues | 讀取當前表單值,不訂閱變動、不觸發 re-render,適合在事件中取值 |
setValue | 程式化設定欄位值,可選擇是否觸發驗證、標記 dirty 或 touched |
setError | 手動設定欄位錯誤,常用於 server 回傳驗證錯誤 |
clearErrors | 清除指定欄位或全部欄位的錯誤 |
reset | 重置整個表單狀態與值,可傳入新值或保留部分狀態 |
resetField | 重置單一欄位的值與狀態 |
trigger | 手動觸發指定欄位或全部欄位的驗證 |
control | 傳給 Controller、useFieldArray、useWatch 等需要存取表單內部的元件 |
formState | 表單整體狀態物件(見下方) |
formState(常用)
| 屬性 | 說明 |
|---|---|
errors | 各欄位的錯誤物件,key 為欄位名,value 含 message、type |
isValid | 所有欄位驗證通過為 true |
isSubmitting | 表單正在送出中(handleSubmit callback 執行期間)為 true |
isSubmitted | 表單曾被送出過為 true,reset 後才會還原 |
isSubmitSuccessful | 送出成功(無 runtime 錯誤)為 true |
isLoading | 非同步 defaultValues 載入中為 true |
formState內部用 Proxy 實作,只有實際解構取用的屬性才會訂閱更新,未使用的屬性不會觸發 re-render。
// 此為官方範例
useEffect(() => {
if (formState.errors.firstName) {
// TODO
}
}, [formState]);
// [formState.errors] useEffect 不會觸發
// formState.isValid 因為是根據條件式存取,所以 Proxy 不會訂閱觸發監聽狀態
return <button disabled={!formState.isDirty || !formState.isValid} />;
// 要透過解構方式才會監聽
const { isDirty, isValid } = formState;
return <button disabled={!isDirty || !isValid} />;實務小技巧
1. 減少傳遞過多層的 React 元件來使用 useForm 相關方法
我們在正常開發時元件不會像範例只有一層,會遇到多層結構等等,我們可以使用官方提供的 FormProvider、useFormContext 來取得,可以把這認定為 React 的 Context Provider 機制,但兩者使用上還是有差異的。
createContext | FormProvider | |
|---|---|---|
| 角色 | React 底層 API | 基於 createContext 的封裝 |
| 狀態管理 | 自己決定放什麼 | 固定放 useForm 回傳的所有方法 |
| 消費方式 | useContext(MyContext) | useFormContext() |
| 彈性 | 完全自由 | 只能用在 RHF 表單 |
| 適用場景 | 任何需要跨層傳遞資料的情境 | 深層子元件需要存取表單方法時 |
function App() {
const methods = useForm();
return (
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit(console.log)}>
<NestedInput />
</form>
</FormProvider>
);
}
function NestedInput() {
const { register } = useFormContext();
return <input {...register('name')} />;
}2. 我想建立 Reusable 的可控表單元件
官方也提供 useController 讓你自訂可重用的 RHF 表單元件,只需傳入 control、name 就可完成。
import { TextField } from '@material-ui/core';
import { useController, useForm } from 'react-hook-form';
function Input({ control, name }) {
const { field } = useController({
name,
control,
});
return (
<TextField
onChange={field.onChange}
onBlur={field.onBlur}
value={field.value}
name={field.name}
inputRef={field.ref}
/>
);
}3. 我想用別的方式取得 formState 值
你可以透過 useFormState 對某個欄位值狀態進行訂閱監聽,而此方式監聽也能減少 re-render 問題,因訂閱狀態只限制在 useFormState 這個 scope 底下。
import { useForm, useFormState } from 'react-hook-form';
/**
* 子元件
*/
function Child({ control }) {
const { dirtyFields } = useFormState({ control });
return dirtyFields.firstName ? <p>Field is dirty.</p> : null;
}
/**
* 主元件
*/
export default function App() {
const { register, handleSubmit, control } = useForm({
defaultValues: {
firstName: 'firstName',
},
});
const onSubmit = (data) => console.log(data);
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('firstName')} placeholder="First Name" />
<Child control={control} />
<input type="submit" />
</form>
);
}RHF 常見問題
1. 如果我的 Schema 有定義巢狀欄位呢?
const schema = z.object({
address: z.object({
city: z.string().min(1, '城市為必填'),
zip: z.string().regex(/^\d{5}$/, '請輸入 5 碼郵遞區號'),
}),
});
// 使用點記法存取巢狀欄位
<input {...register('address.city')} />;
{
errors.address?.city && <p>{errors.address.city.message}</p>;
}2. 如果我的 Schema 有定義陣列欄位呢?
透過官方提供的 useFieldArray 先將 useForm 給予的 control 丟給該 hook 再指定 name 欄位名稱進行綁定,回傳方法 點我查看(其實就跟普通陣列操作一樣,這裡就不贅述)。
import zod from 'zod';
import { useForm, useFieldArray } from 'react-hook-form';
import { standardSchemaResolver } from '@hookform/resolvers/standard-schema';
const schema = zod.object({
parties: zod
.array(zod.object({ name: zod.string().min(1, '姓名為必填') }))
.min(1, '至少需要一位當事者'),
});
export default function MyForm() {
const {
register,
control,
formState: { errors },
} = useForm({
resolver: standardSchemaResolver(schema),
defaultValues: { parties: [{ name: '' }] },
});
const { fields, append, remove } = useFieldArray({
control, // 接取 useForm 的 control 確保能讀取到欄位
name: 'parties', // 訂閱欄位值
});
return (
<div>
{fields.map((field, i) => (
<div key={field.id}>
<input {...register(`parties.${i}.name`)} />
{errors.parties?.[i]?.name && <p>{errors.parties[i].name.message}</p>}
{i > 0 && (
<button type="button" onClick={() => remove(i)}>
移除
</button>
)}
</div>
))}
<button type="button" onClick={() => append({ name: '' })}>
新增
</button>
</div>
);
}3. 我發現某些第三方元件儘管透過 register 訂閱後表單狀態值還是不會改變?
有些第三方元件不提供 ref 的方式來控制元件(register 就是透過 ref 接管 input 值),不過 RHF 也有提供相關方式解決這問題,官方提供 Controller wrapper 元件,讓你把第三方或自己客製的元件包覆起來,並提供 props 讓您自行呼叫表單更新。點我查看
import { useForm, Controller } from "react-hook-form";
import { Checkbox } from "@material-ui/core";
function App() {
const { handleSubmit, control } = useForm({
defaultValues: {
isViewMode: false,
},
});
const onSubmit = (data) => console.log(data);
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Controller
name="isViewMode"
control={control}
render={({ field }) => <Checkbox {...field} />}
{/* 這裡 field 會接收 ref、onChange、onBlur 等等 */}
/>
<input type="submit" />
</form>
);
}4. 我發現我使用 useForm 的 watch 監聽欄位時我的元件會發生 re-render 問題
不錯!你看到這問題的話果然是未來前端棟樑,官方提供了 useWatch 的 hook 讓你可以監聽欄位值變化,不同的是這 hook 也可以防止元件 re-render 問題。點我查看
注意! 這邊訂閱的執行順序非常之重要,這邊用官方提供舉例,當你的狀態值在`useWatch`訂閱之前就更新會無法監聽,必須訂閱後才會開始監聽狀態值。
setValue("test", "data")
useWatch({ name: "test" }) // subscription is happened after value update, no update received
useWatch({ name: "example" }) // input value update will be received and trigger re-render
setValue("example", "data")import { useForm, useWatch } from 'react-hook-form';
/**
* 監聽 FirstName 欄位
*/
function FirstNameWatched({ control }) {
const firstName = useWatch({
control,
name: 'firstName', // 1. 空值的話會監聽整個表單 2. 指定值監聽指定欄位 3. 給 string[] 可監聽多欄位
defaultValue: 'default', // 預設值可在初始渲染時設定
});
return <p>Watch: {firstName}</p>; // 只會 re-render 在 useWatch 所訂閱的元件層
}
/**
* 主元件
*/
function App() {
const { register, control, handleSubmit } = useForm();
const onSubmit = (data) => {
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<label>First Name:</label>
<input {...register('firstName')} />
<input {...register('lastName')} />
<input type="submit" />
<FirstNameWatched control={control} />
</form>
);
}其他補充
RHF 支援的 Schema 套件(常見)
透過 @hookform/resolvers 套件提供各種 schema 的 resolver,目前常見的為以下,想看更多其他支援可以點我查看:
| 套件 | resolver |
|---|---|
| Zod | zodResolver |
| Yup | yupResolver |
| Joi | joiResolver |
| Valibot | valibotResolver |