React Native 奇幻之旅(16)-表單驗證 (React-hook-form & zod)

React Native logo

幾乎所有應用程式都不可避免地需要使用表單(例如登入或註冊),因此表單的處理特別重要。

通常來說需要限制輸入的型別、長度以及格式,當表單一複雜時,判斷和驗證的程式碼就會很冗長。如果在一開始沒有規劃好,則程式碼的可讀性和可維護性都會受到影響,後期維護起來會很困難。

為了解決這個問題業界許多專案都會使用專門的表單驗證庫,例如 Formik 和 React-hook-form,那今天我會分享 React-hook-form 與 zod 一起使用實作最基本的表單驗證。React-hook-form 有提供比較完善的 API,所以能節省開發時間、提高效率,也方便後期維護。

React-hook-form

優點

  1. 易上手,不需要寫大量的表單邏輯程式碼。
  2. 組件重新渲染時不會影響父子組件重新渲染。
    LwOapmi
  3. 可以訂閱單個值的輸入和表單狀態更新,不需要重新渲染整個表單。
    PE8g7v4
  4. 組件渲染速度快(和其他庫相比)
  5. 業界很多人使用,社區足夠大,有問題基本上都能找到解答。

基本使用

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 為主的型別聲明和驗證的庫。

優點

  1. 零依賴
  2. 很輕巧,才 8kb(minified + zipped)
  3. 提供很多簡潔、可鏈式調用的 API
  4. 也可以用在 JS

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)

基本用法如下:

  1. 全部遍歷
   <Controller
     control={control}
     name="list"
     render={({
       field: { onChange, onBlur, value },
       fieldState: { error },
     }) => {
       return value.map(item =>
         <View key={item}>
           <Text>{item}</Text>
         </View>
       )
     }}
   />
  1. 個別元素
  • name 可以指定索引,比如 list.0,索引也可以是動態的,比如 list.${index}
  • 這種方法適合用在嵌套表單
   <Controller
     control={control}
     name="list.0"
     render={({
       field: { onChange, onBlur, value },
       fieldState: { error },
     }) => (
        <View>
          <Text>{value}</Text>
        </View>
     )}
   />

參考資料

留言

目前沒有留言。

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *