📝

react-hook-form を使用してフォームを作成 ー カスタムリゾルバ

に公開

はじめに

  • カスタムリゾルバを作成しました
  • 「react-hook-formを使用してフォームを作成 ー カスタムバリデーション」で作成したサンプルフォームを更新しました(カスタムバリデーションからカスタムリゾルバを使用したバリデーションに変更しました)

ライブラリ

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

イメージ

HOOKS部分

React Hook Form Resolvers

カスタムリゾルバ

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

import * as ReactHookForm from 'react-hook-form'
import * as HookformResolvers from '@hookform/resolvers'
import {ValidationRules} from '@/features/validator/rules/validation.rules'
import {ValidationOptions} from '@/features/validator/props/varidation.options'
import {ValidationCommands} from '@/features/validator/props/validation.commands'
import {validation} from '@/features/validator/validator'
import getValue from '@/features/validator/react.hook.form/utils/get.value'
import getValidationRules from '@/features/validator/react.hook.form/utils/get.validation.rules'
import buildNames from '@/features/validator/react.hook.form/utils/build.names'

const parseErrorSchema = (errores: Record<string, string[]>, validateAllFieldCriteria: boolean): ReactHookForm.FieldErrors<Record<string, unknown>> =>  {
  return Object.keys(errores).reduce<Record<string, ReactHookForm.FieldError>>((previous, path) => {
    if (!previous[path]) {
      previous[path] = { 
        message: errores[path].join('\n'),
        type: '',
      }
    }
    if (validateAllFieldCriteria) {
      for (const message of errores[path]) {
        const types = previous[path].types || {}

        previous[path].types = {
          ...types,
          [Object.keys(types).length]: message,
        }
      }
    }
    return previous
  }, {})
}

export const customResolver = <_S extends Record<keyof _S, ValidationRules> = Record<string, ValidationRules>, _T extends ReactHookForm.FieldValues = Record<string, unknown>> (
  schema: _S
): ReactHookForm.Resolver<_T> => {
  return async (objet, _, options) => {
    const validateAllFieldCriteria = !options.shouldUseNativeValidation && options.criteriaMode === 'all'
    const names = validateAllFieldCriteria
      ? buildNames(objet)
      : options.names || []

    const validationOptions: ValidationOptions = {
      abortFirstError: !validateAllFieldCriteria,
    }

    const validationCommands: ValidationCommands = {
      ref: (name) => {
        return getValue(objet, name)
      },
    }
    
    const errores: Record<string, string[]> = {}

    for (const name of names) {
      const validationRules = getValidationRules(schema, name)

      if (validationRules) {
        const result = await validation({
          value: getValue(objet, name),
          rules: validationRules,
          options: validationOptions,
          commands: validationCommands,
        })

        if (result.length) {
          errores[name] = result
        }
      }
    }
    
    if (Object.keys(errores).length) {
      return {
        values: {},
        errors: HookformResolvers.toNestErrors(
          parseErrorSchema(errores, validateAllFieldCriteria),
          options
        ),
      }
    }
    
    if (options.shouldUseNativeValidation) {
      HookformResolvers.validateFieldsNatively({}, options)
    }

    return {
      values: Object.assign({}, objet),
      errors: {},
    }
  }
}

ファクトリー関数:customResolver

export const customResolver = <_S extends Record<keyof _S, ValidationRules> = Record<string, ValidationRules>, _T extends ReactHookForm.FieldValues = Record<string, unknown>> (
  schema: _S
): ReactHookForm.Resolver<_T> => {
      ~~~省略~~~
}

ファクトリー関数のパラメータ(カスタムリゾルバのオプション)

schema

  • バリデーションルールを定義したオブジェクトを登録

React Hook Form とカスタムリゾルバの紐づけ

バリデーションルール

  • バリデーションルールを定義
const loginIdValidationRule = {
  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: '記号は @ - _ . のみ使用できます',
      },
    },
  ],
} as ValidationRules

useForm フックに登録

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

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

resolver
  • カスタムリゾルバを登録
  • カスタムリゾルバのオプション schema にバリデーションルールを定義したオブジェクトを登録

バリデーションを実施する関数

export const customResolver = <_S extends Record<keyof _S, ValidationRules> = Record<string, ValidationRules>, _T extends ReactHookForm.FieldValues = Record<string, unknown>> (
  schema: _S
): ReactHookForm.Resolver<_T> => {
  return async (objet, _, options) => {
      ~~~省略~~~
  }
}
  • カスタムリゾルバのファクトリー関数はバリデーションを実施する関数を返す
  • React Hook Form は バリデーションを実施する関数を実行してバリデーションを実施

バリデーションを実施する関数のパラメータ

objet

  • 各フィールドで入力した値は、 React Hook Form の useForm フック内部で入力値格納オブジェクトに集約

options

options.shouldUseNativeValidation
  • useForm フックの shouldUseNativeValidation オプションに設定した値
options.criteriaMode
  • useForm フックの criteriaMode オプションに設定した値
options.names
  • バリデーション対象のフィールド名を格納した配列
  • React Hook Form の register 関数の name プロパティに登録した値が格納される
    • onSubmit 時に格納される値(例)

      names:
      0: "loginId"
      1: "password"
      2: "confirmPassword"
      3: "nickName"
      
    • onChange 時に格納される値(例)

      names:
      0: "loginId"
      
options.fields
  • 格納される値は、React Hook Form 内部の実装に依存する
    • onSubmit 時に格納される値(例)

      fields:
        loginId: {ref: input#ᆱr6ᄏ…, name: 'loginId', …}
        password: {ref: input#ᆱr7ᄏ-…, name: 'password', …}
        confirmPassword: {ref: input#ᆱr9ᄏ…, name: 'confirmPassword', …}
        nickName: {ref: input#ᆱrbᄏ…, name: 'nickName', …}
      
    • onChange 時に格納される値(例)

      names: Array(4)
      loginId: {ref: input#ᆱr6ᄏ…, name: 'loginId', …}
      

入力値格納オブジェクトのキーと入力フィールドとの紐づけ

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

  • 入力値格納オブジェクトのキーを入力コンポーネントに登録
  <ReactHookForm.FormProvider {...reactHookForm}>
    <TextFild<LocalUserRegistrationRequestObject>
      name = {'loginId'}
      ~~~省略~~~
    />
    <PasswordFild<LocalUserRegistrationRequestObject> 
      name = {'password'}
      ~~~省略~~~
    />
    <PasswordFild<LocalUserRegistrationRequestObject>
      name = {'confirmPassword'}
      ~~~省略~~~
    />
    <TextFild<LocalUserRegistrationRequestObject>
      name = {'nickName'}
      ~~~省略~~~
    />

  </ReactHookForm.FormProvider>

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

name
  • 入力値格納オブジェクトのキーを登録

入力フィールドに登録

  • React Hook Form の register 関数を使用して MUI TextField に登録
const registerProps = {
  setValueAs: (value) => value.trim(),
} as StringRegisterOptions

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, registerProps)} 
      ~~~省略~~~
    />
  )
}

register 関数のプロパティ

name
  • 入力値格納オブジェクトのキーを登録

バリデーション(onSubmit)

バリデーション実施

  • handleSubmit 内で、カスタムリゾルバのバリデーションを実施する関数が実行される

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

バリデーション処理

  • バリデーションを実施する関数内の処理
  return async (objet, _, options) => {
    const validateAllFieldCriteria = !options.shouldUseNativeValidation && options.criteriaMode === 'all'
    const names = validateAllFieldCriteria
      ? buildNames(objet)
      : options.names || []
      ~~~省略~~~
    for (const name of names) {
      const validationRules = getValidationRules(schema, name)

      if (validationRules) {
        const result = await validation({
          value: getValue(objet, name),
          rules: validationRules,
          options: validationOptions,
          commands: validationCommands,
        })

        if (result.length) {
          errores[name] = result
        }
      }
    }
      ~~~省略~~~
  }

バリデーション対象

  • バリデーション対象は、options.names に格納された値
    names:
    0: "loginId"
    1: "password"
    2: "confirmPassword"
    3: "nickName"
    

バリデーションエラー有

  • エラーオブジェクトの構造を React Hook Form の構造に変換
  • サンプルでは、MUI の helperText で扱いやすい構造に変換
  return async (objet, _, options) => {
      ~~~省略~~~
    if (Object.keys(errores).length) {
      return {
        values: {},
        errors: HookformResolvers.toNestErrors(
          parseErrorSchema(errores, validateAllFieldCriteria),
          options
        ),
      }
    }
      ~~~省略~~~
  }
const parseErrorSchema = (errores: Record<string, string[]>, validateAllFieldCriteria: boolean): ReactHookForm.FieldErrors<Record<string, unknown>> =>  {
  return Object.keys(errores).reduce<Record<string, ReactHookForm.FieldError>>((previous, path) => {
    if (!previous[path]) {
      previous[path] = { 
        message: errores[path].join('\n'),
        type: '',
      }
    }
      ~~~省略~~~
    return previous
  }, {})
}

https://github.com/react-hook-form/resolvers/blob/v5.0.1/src/toNestErrors.ts#L12

エラーメッセージ表示

  • 入力コンポーネント内で 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}
    />
  )
}

バリデーションエラー無

  • 入力値格納オブジェクトの変換処理を実施
  • React Hook Form の handleSubmit に登録した、submit コールバック関数のパラメータには、変換処理後の値が設定される
  return async (objet, _, options) => {
      ~~~省略~~~
    return {
      values: Object.assign({}, objet),
      errors: {},
    }
  }

バリデーション(onChange)

バリデーション実施

  • onChange 内で、カスタムリゾルバのバリデーションを実施する関数が実行される

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

バリデーション処理

バリデーション対象

  • バリデーション対象は、options.names に格納された値
  • 入力操作を行った入力フィールドに紐づく React Hook Form の register 関数の name プロパティに登録した値が格納される(入力操作を行ったフィールドのバリデーションを実施)
    names:
      0: "loginId"
    

エラーメッセージ表示

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

個別バリデーション

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

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

バリデーションルール

  • バリデーションルールにパスワードフィールドの入力値を参照する記述を追加
const confirmPasswordValidationRule = {
      ~~~省略~~~
  isEquale: {
    conditions: '%%password%%',
    message: 'パスワードが異なっています',
  },
} as ValidationRules

カスタムリゾルバ

  • コールバック関数 ref を作成して、バリデーション処理のパラメータで渡す
  return async (objet, _, options) => {
      ~~~省略~~~
    const validationCommands: ValidationCommands = {
      ref: (name) => {
        return getValue(objet, name)
      },
    }
      ~~~省略~~~
        const result = await validation({
          value: getValue(objet, name),
          rules: validationRules,
          options: validationOptions,
          commands: validationCommands,
        })
      ~~~省略~~~
  }

バリデーション処理

  • コールバック関数 ref を実行して参照する値を取得
      if (typeof isEqualeRule.conditions === 'string' &&  isEqualeRule.conditions.startsWith('%%') &&  isEqualeRule.conditions.endsWith('%%')) {
        const refed = props.commands.ref(isEqualeRule.conditions.slice(2, -2).trim())
    ~~~省略~~~
      }

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

  • 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])

バリデーション対象

  • options.names には、trigger 関数のパラメータに設定した 'confirmPassword' が格納される
    names:
      0: "confirmPassword"
    

ソースコード

  • 利用者登録コンポーネント(サンプル)のソースコード
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 {ValidationRules} from '@/features/validator/rules/validation.rules'
import {MatchingPatterns} from '@/features/validator/patterns/matching.patterns'
import {customResolver} from '@/features/validator/react.hook.form/custom.resolver'
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
}

interface Schema {
  loginId: ValidationRules
  password: ValidationRules
  confirmPassword: ValidationRules
  nickName: ValidationRules
}

const loginIdValidationRule = {
  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: '記号は @ - _ . のみ使用できます',
      },
    },
  ],
} as ValidationRules

const passwordValidationRule = {
  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: '記号を含めてください',
      },
    },
  ],
} as ValidationRules

const confirmPasswordValidationRule = {
  required: {
    message: 'パスワード(確認用)を入力してください',
  },
  isEquale: {
    conditions: '%%password%%',
    message: 'パスワードが異なっています',
  },
} as ValidationRules

const nickNameValidationRule = {
  required: {
    message: 'ニックネームを入力してください',
  },
  maxLength: {
    conditions: MatchingPatterns.DB_WIDTH_VARCHAR_MAX,
    message: `${MatchingPatterns.DB_WIDTH_VARCHAR_MAX}文字以内で入力してください`,
  },
} as ValidationRules

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

  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): 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'}
        />
        <PasswordFild<LocalUserRegistrationRequestObject> 
          name = {'password'}
          label = {'パスワード'}
          autoComplete = {'none'}
        />
        <PasswordFild<LocalUserRegistrationRequestObject>
          name = {'confirmPassword'}
          label = {'パスワード(確認用)'}
          autoComplete = {'none'}
        />
        <TextFild<LocalUserRegistrationRequestObject>
          name = {'nickName'}
          label = {'ニックネーム'}
          autoComplete = {'nickname'}
        />
        <SubmitButton 
          label = {'登録'}
          icon = {ICONS.AppRegistration}
        />
      </ReactHookForm.FormProvider>
    </UserFromLayout>
  )
}

Discussion