幾乎所有應用程式都不可避免地需要使用表單(例如登入或註冊),因此表單的處理特別重要。
通常來說需要限制輸入的型別、長度以及格式,當表單一複雜時,判斷和驗證的程式碼就會很冗長。如果在一開始沒有規劃好,則程式碼的可讀性和可維護性都會受到影響,後期維護起來會很困難。
為了解決這個問題業界許多專案都會使用專門的表單驗證庫,例如 Formik 和 React-hook-form,那今天我會分享 React-hook-form 與 zod 一起使用實作最基本的表單驗證。React-hook-form 有提供比較完善的 API,所以能節省開發時間、提高效率,也方便後期維護。
React-hook-form
優點
- 易上手,不需要寫大量的表單邏輯程式碼。
- 組件重新渲染時不會影響父子組件重新渲染。
- 可以訂閱單個值的輸入和表單狀態更新,不需要重新渲染整個表單。
- 組件渲染速度快(和其他庫相比)
- 業界很多人使用,社區足夠大,有問題基本上都能找到解答。
基本使用
react-hook-form 在 React 和 React Native 寫法稍微有點差異,因為 RN 不能使用 register 的方式來管理輸入,需要使用 control 來控制。
有兩種方式,一種是直接使用 <Controller>
組件,另一種則是用 useController
hook
Controller
- useForm: 用於創建表單物件,會返回 control, handleSubmit, formState…等
- Controller
- control: 該物件包含將組件註冊到 react-hook-form 中的方法
- name: 對應的表單 field name
// ...
import { TextInput } from 'react-native'
import { useForm, Controller } from 'react-hook-form'
export const Form = () => {
const { control, handleSubmit, formState: { errors }} = useForm({
defaultValues: {
email: '',
account: '',
password: ''
}
})
return (
<Controller
name="email"
control={control}
rules={{
required: true,
pattern: {
value: /\S+@\S+\.\S+/,
message: 'Invalid Email'
}
}}
render={({
field: { value, onChange, onBlur },
fieldState: { error }
}) => (
<TextInput
keyboardType="email-address"
textContentType="emailAddress"
value={value}
onBlur={onBlur}
onChangeText={onChange}
/>
)}
/>
// ...
)
}
useController
個人更喜歡用 useController hook 封裝成一個表單輸入框組件來共用,這樣就不需要每個頁面都用 Controller 組件:
// FormInput.tsx
import { useController } from 'react-hook-form'
import { TextInput } from 'react-native'
// ...
export const FormInput = ({
name,
control,
rules,
...restProps
}: FormInputProps) => {
const { field, fieldState: { error } } = useController({
name,
control,
defaultValue: '',
rules,
})
return (
<TextInput
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
{...restProps}
/>
)
}
// Form.tsx
import { useForm } from 'react-hook-form'
import { FormInput } from '@/components/atoms'
export const Form = () => {
const { control, handleSubmit, formState: { errors }} = useForm({
defaultValues: {
email: '',
account: '',
password: ''
}
})
return (
<FormInput
name="email"
control={control}
rules={{
required: true,
pattern: {
value: /\S+@\S+\.\S+/,
message: 'Invalid Email'
}
}}
keyboardType="email-address"
textContentType="emailAddress"
/>
)
}
zod
zod 是以 TS 為主的型別聲明和驗證的庫。
優點
- 零依賴
- 很輕巧,才 8kb(minified + zipped)
- 提供很多簡潔、可鏈式調用的 API
- 也可以用在 JS
zod 的生態系統也很完善,有很多相關的第三方庫可以選擇:
- ts-to-zod: 將 TS 型別直接轉為 zod schema
- zod-mocking: 根據 zod schema 生成有效、無效的測試資料 (zodock 也是)
- zod-i18n-map: 用於翻譯 zod 的默認錯誤消息
基本使用
建立 Schema
假設我們有一個註冊表單資料型別長這樣,現在要為它建立 schema:
type SignUpFormData = {
email: string;
password: string;
}
使用 z.object
就可以建立 Obejct Schema:
z.string().min(8, {})
:字串、最小長度為8(必填){ message: i18n.t('Error.invalidEmail') }
:不符合信箱格式時的錯誤訊息z.infer
:將 schema 轉為 type
import { z } from 'zod'
export const signUpFormSchema = z.object({
email: z.string().email(
{ message: '錯誤的信箱格式' }
),
password: z.string().min(8, {
message: '密碼最短需要8個字符'
})
})
export type SignUpFormSchemaType = z.infer<typeof signUpFormSchema>
更多寫法請看官方文檔。
使用 react-hook-form 與 zod 實作表單驗證
因為要使用解析器(resolver)所以需要先安裝 @hookform/resolvers
npm install @hookform/resolvers
建立表單
建立一個有 email
, password
欄位的表單:
import { View, TextInput, Text } from 'react-native'
import { useForm, Controller } from 'react-hook-form'
export const SignUpForm = () => {
const { control } = useForm({
defaultValues: {
email: '',
password: ''
},
mode: 'onChange'
})
return (
<View>
<Controller
control={control}
name="email"
render={({
field: { onChange, onBlur, value },
fieldState: { error },
}) => {
return (
<View>
<Text>Email</Text>
<TextInput
onBlur={onBlur}
value={value}
onChangeText={onChange}
/>
{!!error?.message && <Text>{error.message}</Text>}
</View>
);
}}
/>
<Controller
control={control}
name="password"
render={({
field: { onChange, onBlur, value },
fieldState: { error },
}) => {
return (
<View>
<Text>Password</Text>
<TextInput
onBlur={onBlur}
value={value}
onChangeText={onChange}
/>
{!!error?.message && <Text>{error.message}</Text>}
</View>
);
}}
/>
</View>
)
}
定義表單 Schema
- email: 必填,需符合 Email 格式
- password: 必填,至少要輸入 8 個字符以上
import { z } from 'zod'
export const signUpFormSchema = z.object({
email: z.string().email(
{ message: '錯誤的信箱格式' }
),
password: z.string().min(8, {
message: '密碼最短需要8個字符'
})
})
export type SignUpFormSchemaType = z.infer<typeof signUpFormSchema>
useForm 結合 zod 使用
要使用 zod schema 來進行表單驗證需要使用 zodResolver(schema)
作為 useForm 的 resolver:
// ...
import { useForm, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { signUpFormSchema, type SignUpFormSchemaType } from '@/helpers/validate/SignUp'
export const SignUpForm = () => {
const { control, handleSubmit } = useForm<SignUpFormSchemaType>({
resolver: zodResolver(signUpFormSchema),
defaultValues: {
email: '',
password: ''
},
mode: 'onChange'
})
// ...
}
這樣就能確保表單輸入的資料符合 signUpFormSchema 中所定義的驗證規則。
送出與驗證表單
handleSubmit(onSubmit, onError)
- onSubmit: 驗證通過後執行的 callback
- onError: 驗證失敗後執行的 callback
export const Form = () => {
const { control, handleSubmit } = useForm <SignUpFormSchemaType> ({
resolver: zodResolver(signUpFormSchema),
defaultValues: {
email: '',
password: ''
},
mode: 'onChange'
})
const onSubmit: SubmitHandler<SignUpFormSchemaType> = (formData) => {
// {"email": "test@gmail.com", "password": "12345678"}
console.log(formData)
}
const onError: SubmitErrorHandler<SignUpFormSchemaType> = (errors) => {
console.log(errors)
}
return (
<View>
// ...
<Button onPress={handleSubmit(onSubmit, onError)}>
Submit
</Button>
</View>
)
}
若表單驗證無效則會回傳 Error object,key 為 field name,Error Object 格式如下:
{
"email": {
"message": "無效的電子郵件",
"ref": {"name": "email"},
"type": "invalid_string"
},
"password": {
"message": "最少長度應為 8",
"ref": {"name": "password"},
"type": "too_small"
}
}
onError
可以獲取表單驗證錯誤的欄位和訊息,除此之外使用 useForm 回傳的formState.errors
也可以獲取到同樣的內容。
表單的條件判斷
有些表單會有需要條件判斷的需求,比如說性別選擇男性的話顯示男性的表單,選擇女性顯示女性的表單,這時候就可以使用 watch
函數來監聽選擇的性別選項為何:
import { View, TextInput, Text } from 'react-native'
import { useForm, Controller } from 'react-hook-form'
// ...
export const Form = () => {
// ...
const { control, watch, handleSubmit } = useForm({
defaultValues: { gender: 'female' },
mode: 'onChange'
})
return (
<View>
<Controller
name="gender"
control={control}
render={({ field: { onChange, value }}) => (
<>
<RadioButton
value="female"
status={value === 'female' ? 'checked' : 'unchecked'}
onPress={onChange}
/>
<RadioButton
value="male"
status={value === 'male' ? 'checked' : 'unchecked'}
onPress={onChange}
/>
</>
)}
/>
{watch('gender') === 'female'
? <FemaleForm />
: <MaleForm />
}
</View>
)
}
嵌套表單
父表單使用 FormProvider
可以將表單物件傳遞給子表單:
import { useForm, FormProvider } from 'react-hook-form'
// ...
export const ParentForm = () => {
const methods = useForm<FormSchemaType>({
resolver: zodResolver(FormSchema),
defaultValues: DEFAULT_VALUES
})
const { control, handleSubmit } = methods
return (
<FormProvider {...methods}>
// ...
<ChildForm />
</FormProvider>
)
}
子表單使用 useFormContext
可以獲取到父表單的表單物件,搭配 useWatch
還以監聽父表單的所有欄位資料更新:
// ChildForm.tsx
import { useFormContext, useWatch } from 'react-hook-form'
const ChildForm = () => {
const { control, formState } = useFormContext()
const parentFormData = useWatch({ control })
console.log('parentFormData', parentFormData)
// parentFormData {"email": "test", "password": "1234"}
return (
<View />
)
}
管理陣列型別資料
使用 useFieldArray
hook 可以管理陣列型別的資料:
import { useForm, useFieldArray } from 'react-hook-form'
export const Form = () => {
const { control } = useForm<FormData>({
defaultValues: { list: [] }
})
const { fields, append, update, remove } = useFieldArray({
control,
name: 'list',
})
// ...
}
append(value)
: 新增元素update(index, value)
: 更新指定索引元素remove(index)
: 刪除指定索引元素
append('string')
update(1, 'string')
remove(1)
基本用法如下:
- 全部遍歷
<Controller
control={control}
name="list"
render={({
field: { onChange, onBlur, value },
fieldState: { error },
}) => {
return value.map(item =>
<View key={item}>
<Text>{item}</Text>
</View>
)
}}
/>
- 個別元素
- name 可以指定索引,比如
list.0
,索引也可以是動態的,比如list.${index}
- 這種方法適合用在嵌套表單
<Controller
control={control}
name="list.0"
render={({
field: { onChange, onBlur, value },
fieldState: { error },
}) => (
<View>
<Text>{value}</Text>
</View>
)}
/>