📝
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
カスタムリゾルバ
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 内で、カスタムリゾルバのバリデーションを実施する関数が実行される
バリデーション処理
- バリデーションを実施する関数内の処理
  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
  }, {})
}
エラーメッセージ表示
- 入力コンポーネント内で 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 内で、カスタムリゾルバのバリデーションを実施する関数が実行される
バリデーション処理
バリデーション対象
- バリデーション対象は、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