😺

react-hook-formを使用してフォームを作成 ー カスタムバリデーション

に公開

はじめに

  • react-hook-form を使用して登録フォームのサンプルを作成しました
  • UI 部分は MUI を使用しています

ライブラリ

ライブラリ バージョン
react ^19.0.0
react-hook-form ^7.54.2
@mui/material ^7.0.1
vite ^6.2.0

イメージ

フォーム管理ライブラリ

React Hook Form で行いたい機能

状態管理

  • 各フィールドの入力値
  • バリデーション実施結果

入力フィールドとの紐づけ

  • バリデーションルールと入力フィールドとの紐づけ
  • 入力値格納オブジェクトと入力フィールドの紐づけ
  • 入力した値は入力値格納オブジェクトに自動で格納

バリデーション

  • バリデーションルールに従って自動でバリデーションを実施
  • バリデーションルールを任意にカスタマイズ
  • バリデーション実施タイミングを任意にカスタマイズ

UI部分

部品化

名称 コンポーネント 機能
フォームコンポーネント UserFromLayout フォームのレイアウト
入力コンポーネント TextFild テキスト入力
入力コンポーネント PasswordFild パスワード入力
ボタンコンポーネント SubmitButton サブミットボタン

MUI

名称 MUI DOM
入力フォーム Box component=form form
入力フィールド TextField type=text input type=text
入力フィールド TextField type=password input type=password
サブミットボタン Button type=submit button type=submit
  return (
    <UserFromLayout
      title = {'利用者登録'}
      onSubmit = {reactHookForm.handleSubmit(submit)}
    >
      <ReactHookForm.FormProvider {...reactHookForm}>
        <TextFild<LocalUserRegistrationRequestObject>
          name = {'loginId'}
          label = {'ログインID'}
          autoComplete = {'username'}
          registerProps = {loginIdRegisterProps}
        />
        <PasswordFild<LocalUserRegistrationRequestObject> 
          name = {'password'}
          label = {'パスワード'}
          autoComplete = {'none'}
          registerProps = {passwordRegisterProps}
        />
        <PasswordFild<LocalUserRegistrationRequestObject>
          name = {'confirmPassword'}
          label = {'パスワード(確認用)'}
          autoComplete = {'none'}
          registerProps = {confirmPasswordRegisterProps}
        />
        <TextFild<LocalUserRegistrationRequestObject>
          name = {'nickName'}
          label = {'ニックネーム'}
          autoComplete = {'nickname'}
          registerProps = {nickNameRegisterProps}
        />
        <SubmitButton 
          label = {'登録'}
          icon = {ICONS.AppRegistration}
        />
      </ReactHookForm.FormProvider>
    </UserFromLayout>
  )

HOOKS部分

React Hook Form と入力コンポーネントの紐づけ

useForm フックの作成

React Hook Form の useForm フックのオプション

  const reactHookForm = ReactHookForm.useForm<LocalUserRegistrationRequestObject>({
    mode: 'onSubmit',
    reValidateMode: 'onChange',
    delayError: 200,
    defaultValues: {
      loginId: '',
      password: '',
      confirmPassword: '',
      nickName: '',
    } as LocalUserRegistrationRequestObject,
  })
mode
  • 登録ボタン押下前のバリデーションを実施するタイミングを設定
タイミング
onSubmit 入力フォームの onSubmit イベント
onChange 入力フィールドの onChange イベント
onBlur 入力フィールドの onBlur イベント
onTouched 最初の onBlur イベント、その後は、onChage イベント
all onChange イベントと onBlur イベントの両方
reValidateMode
  • 登録ボタン押下後のバリデーションを実施するタイミングを設定
タイミング
onSubmit onSubmit = onChange
onChange 入力フィールドの onChange イベント
onBlur 入力フィールドの onBlur イベント
delayError
  • エラーメッセ時の表示をバリデーションエラーを検知した時点から遅らせる設定(ミリ秒)
defaultValues
  • 入力フィールドの初期値を設定

FormProvider に登録

  • useForm フックを React Hook Form の FormProvider に登録
  <ReactHookForm.FormProvider {...reactHookForm}>
      ~~~省略~~~
  </ReactHookForm.FormProvider>

入力コンポーネントで参照

  • 入力コンポーネント内で React Hook Form の useFormContext を使用し FormProvider に登録した useForm フックを参照
export function TextFild<_T extends ReactHookForm.FieldValues = Record<string, unknown>>(props: Props<_T>) {
  const reactHookForm = ReactHookForm.useFormContext<_T>()
      ~~~省略~~~
}

入力値格納オブジェクトのキーやバリデーションルールと入力フィールドとの紐づけ

バリデーションルール

  • バリデーションルール( registerProps )を定義
  const loginIdRegisterProps = {
    validate: (value) => validation(value, {
      required: {
        message: 'ログインIDを入力してください',
      },
      maxLength: {
        conditions: MatchingPatterns.DB_ASCII_VARCHAR_MAX,
        message: `${MatchingPatterns.DB_ASCII_VARCHAR_MAX}文字以内で入力してください`,
      },
      rules: [
        {
          isAscii: {
            message: '半角英数字記号で入力してください',
          },
        },
        {
          matche: {
            conditions: MatchingPatterns.USER_LOGIN_ID_ALLOWED_CHARACTERS,
            message: '記号は @ - _ . のみ使用できます',
          },
        },
      ],
    }),
    setValueAs: (value) => value.trim(),
  } as FormTypes.StringRegisterOptions

validate

  • カスタムバリデーションを実施するコールバック関数
    • サンプルでは validation コールバック関数を登録

setValueAs

  • 入力フィールドのバリデーションを実施する前に実行されるコールバック関数
    • サンプルでは入力値の前後の空白スペースを削除

入力コンポーネントに登録

  • 入力値格納オブジェクトのキー、および、バリデーションルールを入力コンポーネントに登録
  <TextFild<LocalUserRegistrationRequestObject>
    name = {'loginId'}
      ~~~省略~~~
    registerProps = {loginIdRegisterProps}
      ~~~省略~~~
  />

入力コンポーネントのプロパティ

name
  • 入力値格納オブジェクトのキーを登録
    • 入力した値やバリデーション実施結果を参照する時に使用
registerProps
  • バリデーションルールを登録

入力フィールドに登録

  • React Hook Form の register 関数を使用して MUI TextField に登録
export function TextFild<_T extends ReactHookForm.FieldValues = Record<string, unknown>>(props: Props<_T>) {
  const reactHookForm = ReactHookForm.useFormContext<_T>()
      ~~~省略~~~
  return (
    <MUI.TextField
      ~~~省略~~~
      {...reactHookForm.register(props.name, props.registerProps)} 
      ~~~省略~~~
    />
  )
}

register 関数のプロパティ

name
  • 入力値格納オブジェクトのキーを登録
    • 入力値格納オブジェクトと入力フィールドが紐づく
options
  • バリデーションルールを登録
    • バリデーションルールと入力フィールドが紐づく

https://github.com/react-hook-form/react-hook-form/blob/v7.54.2/src/logic/createFormControl.ts#L1035

React Hook Form とフォームの紐づけ

フォームコンポーネントに登録

  • React Hook Form の handleSubmit コールバック関数をフォームコンポーネントに登録
  <UserFromLayout
      ~~~省略~~~
    onSubmit = {reactHookForm.handleSubmit(submit)}
  >
      ~~~省略~~~

入力フォームに登録

  • フォームコンポーネントに登録した handleSubmit コールバック関数を MUI form の onSubmit に登録
export function UserFromLayout(props: Props) {
      ~~~省略~~~
  return (
      ~~~省略~~~
      <MUI.Box component="form" onSubmit = {props.onSubmit} sx = {{ mt: 4, width: '100%'}} >
      ~~~省略~~~
      </MUI.Box>
    </MUI.Container>
      ~~~省略~~~
  )
}

バリデーション(onSubmit)

登録ボタン押下

  • 登録ボタンを押下(入力フォームの onSubmit イベントが発生)で、React Hook Form の handleSubmit コールバック関数が実行される
  • handleSubmit 実行中は、React Hook Form の formState の isSubmitting に true が設定される

バリデーション実施

  • handleSubmit 内で、バリデーションルールに登録した validation コールバック関数が実行される

https://github.com/react-hook-form/react-hook-form/blob/v7.54.2/src/logic/createFormControl.ts#L1185
https://github.com/react-hook-form/react-hook-form/blob/v7.54.2/src/logic/createFormControl.ts#L470
https://github.com/react-hook-form/react-hook-form/blob/v7.54.2/src/logic/validateField.ts#L227

バリデーション実施結果

  • バリデーション実施結果は、React Hook Form の formState に格納される

エラーメッセージ表示

  • 入力コンポーネント内で formState を参照しエラーメッセージを表示
export function TextFild<_T extends ReactHookForm.FieldValues = Record<string, unknown>>(props: Props<_T>) {
  const reactHookForm = ReactHookForm.useFormContext<_T>()
  const helperText = React.useMemo<string|undefined>(() => reactHookForm.formState.errors[props.name]?.message?.toString(), [reactHookForm.formState, props.name])
      ~~~省略~~~
  return (
    <MUI.TextField
      ~~~省略~~~
      error = {!!helperText}
      helperText = {helperText}
    />
  )
}

バリデーションエラーの表示イメージ

バリデーション(onChange)

文字入力

  • 文字入力(入力フィールドの onChange イベントが発生)すると、React Hook Form の onChange コールバック関数が実行される

https://github.com/react-hook-form/react-hook-form/blob/v7.54.2/src/logic/createFormControl.ts#L702

バリデーション実施

  • onChange 内で、バリデーションルールに登録した validation コールバック関数が実行される

https://github.com/react-hook-form/react-hook-form/blob/v7.54.2/src/logic/createFormControl.ts#L804
https://github.com/react-hook-form/react-hook-form/blob/v7.54.2/src/logic/validateField.ts#L227

バリデーション実施結果

  • バリデーション実施結果は、React Hook Form の formState に格納される

エラーメッセージ表示

  • 入力コンポーネント内で formState を参照しエラーメッセージを表示

個別バリデーション

  • パスワードとパスワード(確認用)の様に関連する入力フィールドが存在する場合は、個別のバリデーション処理を記述

パスワード(確認用)のバリデーションルール

  • React Hook Form の watch 関数を使用してパスワードフィールドの入力値を参照
  const confirmPasswordRegisterProps = {
    validate: (value) => validation(value, {
      ~~~省略~~~
      isEquale: {
        conditions: reactHookForm.getValues('password'),
        message: 'パスワードが異なっています',
      },
    }),
    setValueAs: (value) => value.trim(),
  } as FormTypes.StringRegisterOptions

パスワード入力時のバリデーション

  • React Hook Form の watch 関数を使用してパスワード入力値の変化をリアルタイムに検出
  • 登録ボタンを押下後は React Hook Form の trigger 関数を使用してパスワード(確認用)フィールドのバリデーションを実施
  const currentPassword = reactHookForm.watch('password')
  const {isSubmitted}  = reactHookForm.formState
  const {trigger} = reactHookForm

  React.useEffect(() => {
    if (isSubmitted) {
      trigger('confirmPassword')
    }
  }, [currentPassword, isSubmitted, trigger])

サブミット

登録ボタン押下

  • 登録ボタンを押下(入力フォームの onSubmit イベントが発生)で、React Hook Form の handleSubmit コールバック関数が実行される

フォームの非活性化

  • handleSubmit 実行中は、React Hook Form の formState の isSubmitting に true が設定される
    • 各入力コンポーネント内で isSubmitting を MUI TextField に登録
    • ボタンコンポーネント内で isSubmitting を MUI Button に登録
export function SubmitButton<_T extends ReactHookForm.FieldValues = Record<string, unknown>>(props: Props) {
  const reactHookForm = ReactHookForm.useFormContext<_T>()
  const {isSubmitting}  = reactHookForm.formState

  return (
    <MUI.Box sx = {{ display: 'flex', justifyContent: 'flex-end' }}>
      <MUI.Button 
      ~~~省略~~~
        disabled = {isSubmitting}
        loading = {isSubmitting}
      >
      ~~~省略~~~
      </MUI.Button>
    </MUI.Box>
  )
}

バリデーション実施

  • handleSubmit 内で、バリデーションルールに登録した validation コールバック関数が実行される

バリデーション実施結果判定

  • バリデーションエラーが無ければ、React Hook Form の handleSubmit に登録した、submit コールバック関数が実行される

https://github.com/react-hook-form/react-hook-form/blob/v7.54.2/src/logic/createFormControl.ts#L1195

登録処理

  • 各フィールドで入力した値は、入力値格納オブジェクトに集約して格納されている
  • submit 内で registration 関数を実行して、利用者登録処理を実施
  const submit: ReactHookForm.SubmitHandler<LocalUserRegistrationRequestObject> = async (localUserRegistrationRequestObject: LocalUserRegistrationRequestObject): Promise<void> => {
    return await sessionContext.registration({
      loginId: localUserRegistrationRequestObject.loginId,
      password: localUserRegistrationRequestObject.password,
      nickName: localUserRegistrationRequestObject.nickName,
    })
  }
  • サンプルでは registration 内で例外処理を行う
  const registration = async (userRegistrationRequestObject: UserRegistrationRequestObject): Promise<void> => {
      ~~~省略~~~
    try {
      const userAuthenticatedObject = await UserApi.registration(userRegistrationRequestObject)
      ~~~省略~~~
    } catch(error: unknown) {
      ~~~省略~~~
    }
  }

ソースコード

  • 利用者登録コンポーネント(サンプル)のソースコード
import React from 'react'
import * as ReactHookForm from 'react-hook-form'
import * as ICONS from '@mui/icons-material'
import * as SessionContext from '@/features/hooks/contexts/session.context'
import * as FormTypes from '@/components/parts/form/form.types'
import {validation} from '@/features/validator/validator'
import {MatchingPatterns} from '@/features/validator/matching.patterns'
import {UserFromLayout} from '@/components/user/user.form.layout'
import {TextFild} from '@/components/parts/form/text.fild'
import {PasswordFild} from '@/components/parts/form/password.fild'
import {SubmitButton} from '@/components/parts/form/submit.button'

interface LocalUserRegistrationRequestObject extends SessionContext.UserRegistrationRequestObject {
  confirmPassword: string,
}

export function UserRegist() {
  const sessionContext = SessionContext.useContext()
  const reactHookForm = ReactHookForm.useForm<LocalUserRegistrationRequestObject>({
    mode: 'onSubmit',
    reValidateMode: 'onChange',
    delayError: 200,
    defaultValues: {
      loginId: '',
      password: '',
      confirmPassword: '',
      nickName: '',
    } as LocalUserRegistrationRequestObject,
  })

  const loginIdRegisterProps = {
    validate: (value) => validation(value, {
      required: {
        message: 'ログインIDを入力してください',
      },
      maxLength: {
        conditions: MatchingPatterns.DB_ASCII_VARCHAR_MAX,
        message: `${MatchingPatterns.DB_ASCII_VARCHAR_MAX}文字以内で入力してください`,
      },
      rules: [
        {
          isAscii: {
            message: '半角英数字記号で入力してください',
          },
        },
        {
          matche: {
            conditions: MatchingPatterns.USER_LOGIN_ID_ALLOWED_CHARACTERS,
            message: '記号は @ - _ . のみ使用できます',
          },
        },
      ],
    }),
    setValueAs: (value) => value.trim(),
  } as FormTypes.StringRegisterOptions

  const passwordRegisterProps ={
    validate: (value) => validation(value, {
      required: {
        message: 'パスワードを入力してください',
      },
      minLength: {
        conditions: MatchingPatterns.USER_PASSWORD_MIN_LENGTH,
        message: `${MatchingPatterns.USER_PASSWORD_MIN_LENGTH}文字以上で入力してください`,
      },
      maxLength: {
        conditions: MatchingPatterns.DB_ASCII_VARCHAR_MAX,
        message: `${MatchingPatterns.DB_ASCII_VARCHAR_MAX}文字以内で入力してください`,
      },
      rules: [
        {
          isAscii: {
            message: '半角英数字記号で入力してください',
          },
        },
        {
          matche: {
            conditions: MatchingPatterns.USER_PASSWORd_INCLUDE_LOWERCASE,
            message: '小文字(a ~ z)を含めてください',
          },
        },
        {
          matche: {
            conditions: MatchingPatterns.USER_PASSWORd_INCLUDE_UPPERCASEE,
            message: '大文字(A ~ Z)を含めてください',
          },
        },
        {
          matche: {
            conditions: MatchingPatterns.USER_PASSWORd_INCLUDE_NUMBER,
            message: '数字(0 ~ 9)を含めてください',
          },
        },
        {
          matche: {
            conditions: MatchingPatterns.USER_PASSWORd_INCLUDE_SYMBOL,
            message: '記号を含めてください',
          },
        },
      ],
    }),
    setValueAs: (value) => value.trim(),
  } as FormTypes.StringRegisterOptions

  const confirmPasswordRegisterProps = {
    validate: (value) => validation(value, {
      required: {
        message: 'パスワード(確認用)を入力してください',
      },
      isEquale: {
        conditions: reactHookForm.getValues('password'),
        message: 'パスワードが異なっています',
      },
    }),
    setValueAs: (value) => value.trim(),
  } as FormTypes.StringRegisterOptions

  const nickNameRegisterProps = {
    validate: (value) => validation(value, {
      required: {
        message: 'ニックネームを入力してください',
      },
      maxLength: {
        conditions: MatchingPatterns.DB_WIDTH_VARCHAR_MAX,
        message: `${MatchingPatterns.DB_WIDTH_VARCHAR_MAX}文字以内で入力してください`,
      },
    }),
    setValueAs: (value) => value.trim(),
  } as FormTypes.StringRegisterOptions

  const currentPassword = reactHookForm.watch('password')
  const {isSubmitted}  = reactHookForm.formState
  const {trigger} = reactHookForm

  React.useEffect(() => {
    if (isSubmitted) {
      trigger('confirmPassword')
    }
  }, [currentPassword, isSubmitted, trigger])

  const submit: ReactHookForm.SubmitHandler<LocalUserRegistrationRequestObject> = async (localUserRegistrationRequestObject: LocalUserRegistrationRequestObject): Promise<void> => {
    return await sessionContext.registration({
      loginId: localUserRegistrationRequestObject.loginId,
      password: localUserRegistrationRequestObject.password,
      nickName: localUserRegistrationRequestObject.nickName,
    })
  }

  return (
    <UserFromLayout
      title = {'利用者登録'}
      onSubmit = {reactHookForm.handleSubmit(submit)}
    >
      <ReactHookForm.FormProvider {...reactHookForm}>
        <TextFild<LocalUserRegistrationRequestObject>
          name = {'loginId'}
          label = {'ログインID'}
          autoComplete = {'username'}
          registerProps = {loginIdRegisterProps}
        />
        <PasswordFild<LocalUserRegistrationRequestObject> 
          name = {'password'}
          label = {'パスワード'}
          autoComplete = {'none'}
          registerProps = {passwordRegisterProps}
        />
        <PasswordFild<LocalUserRegistrationRequestObject>
          name = {'confirmPassword'}
          label = {'パスワード(確認用)'}
          autoComplete = {'none'}
          registerProps = {confirmPasswordRegisterProps}
        />
        <TextFild<LocalUserRegistrationRequestObject>
          name = {'nickName'}
          label = {'ニックネーム'}
          autoComplete = {'nickname'}
          registerProps = {nickNameRegisterProps}
        />
        <SubmitButton 
          label = {'登録'}
          icon = {ICONS.AppRegistration}
        />
      </ReactHookForm.FormProvider>
    </UserFromLayout>
  )
}

Discussion