幾乎所有應用程式都不可避免地需要使用表單(例如登入或註冊),因此表單的處理特別重要。
通常來說需要限制輸入的型別、長度以及格式,當表單一複雜時,判斷和驗證的程式碼就會很冗長。如果在一開始沒有規劃好,則程式碼的可讀性和可維護性都會受到影響,後期維護起來會很困難。
為了解決這個問題業界許多專案都會使用專門的表單驗證庫,例如 Formik 和 React-hook-form,那今天我會分享 React-hook-form 與 zod 一起使用實作最基本的表單驗證。React-hook-form 有提供比較完善的 API,所以能節省開發時間、提高效率,也方便後期維護。
react-hook-form 在 React 和 React Native 寫法稍微有點差異,因為 RN 不能使用 register 的方式來管理輸入,需要使用 control 來控制。
有兩種方式,一種是直接使用 <Controller>
組件,另一種則是用 useController
hook
// ...
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 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 是以 TS 為主的型別聲明和驗證的庫。
zod 的生態系統也很完善,有很多相關的第三方庫可以選擇:
假設我們有一個註冊表單資料型別長這樣,現在要為它建立 schema:
type SignUpFormData = {
email: string;
password: string;
}
使用 z.object
就可以建立 Obejct Schema:
z.string().min(8, {})
:字串、最小長度為8(必填){ message: i18n.t('Error.invalidEmail') }
:不符合信箱格式時的錯誤訊息z.infer
:將 schema 轉為 typeimport { 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>
更多寫法請看官方文檔。
因為要使用解析器(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>
)
}
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>
要使用 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)
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>
)
}}
/>
list.0
,索引也可以是動態的,比如 list.${index}
<Controller
control={control}
name="list.0"
render={({
field: { onChange, onBlur, value },
fieldState: { error },
}) => (
<View>
<Text>{value}</Text>
</View>
)}
/>