😎

react-hook-formのバリデーションはrulesを使った方が楽かもしれない

2023/12/31に公開

始めに

Reactでフォームを扱う場合はreact-hook-formがよく使われますが、このライブラリでバリデーションをする場合、スキーマを作ってバリデーションをすることが多いと思います。

スキーマを作って、それをresolverに渡してバリデーションする例
import { yupResolver } from "@hookform/resolvers/yup";
import { useForm } from "react-hook-form";
import * as yup from "yup";

// 自前で用意したUIコンポーネント
import { InputText } from "~/components";

// スキーマを定義する
const formSchema = yup.object({
  text: yup.string().required()
});

type FormValue = yup.Asserts<typeof formSchema>;

const DEFAULT_FORM_VALUE: FormValue = {
  text: ""
};

const Page: FC = () => {
  const { control, handleSubmit } = useForm<FormValue>({
    // スキーマでバリデーションするようにresolverを渡す
    resolver: yupResolver(formSchema),
    defaultValues: DEFAULT_FORM_VALUE
  });
  
  const onSubmit = handleSubmit(
    (data) => {
      console.log("submit", data);
    },
    (err) => {
      console.log("validation error", err);
    }
  );
  
  return (
    <form noValidate onSubmit={onSubmit}>
      <Controller
        name="text"
	control={control}
	// rhfとUIコンポーネントを紐付ける
	render={({ field, fieldState }) => {
	  return (
	    <InputText
	      inputRef={field.ref}
	      value={field.value}
	      errorMessage={fieldState.error?.message}
	      onBlur={field.onBlur}
	      onChange={field.onChange}
	    />
	  )
	}}
      />
      <button type="submit">送信</button>
    </form>
  );
}

UIとバリデーションのロジックを切り離すことでより柔軟性が上がりましたが、逆に以下のようなUIとバリデーションのずれによるバグが出る可能性が生まれました。

  • 必須ラベルをつけたけどrequiredバリデーション設定が漏れていた
  • 文字数表示を用意したけど、バリデーションとUI上の最大文字数にずれが出てしまった
  • 条件分岐でUIの表示/非表示をする時、非表示なのに必須バリデーションが走ってsubmitできなかった

個人的にスキーマとUIの設定のずれは導入前から懸念しており、スキーマから必要なパラメータを取得できないかは事前に調べておりそれで値のずれは解消されそうですが、それでもそもそも設定すること自体忘れてしまう可能性が残ってしまいます。

https://zenn.dev/wintyo/articles/6122304cb56c86#必須、文字数制限などのパラメータをスキーマから参照しやすいか

僕は元々Vuetifyを使っていたこともあり、以下みたいにフィールド単位で直接rulesを設定して、UIに関連するバリデーションは自動で設定するようにしたら上記のような設定漏れの懸念がそもそも起こらないのになぁと思っていました。

https://v2.vuetifyjs.com/ja/components/text-fields/#counter

rulesも組み込んだコンポーネント
<!-- Vue2な上、vue-property-decoratorを使っていて古いコードなので雰囲気だけ見ていただければと思います -->
<template>
  <v-text-field
    :value="value"
    :rules="finalRules"
    :counter="maxLength"
    @input="$emit('input', $event)"
  />
</template>

<script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator";
import { InputValidationRule } from "vuetify";

@Component({
  model: { prop: "value", event: "input" }
})
export default class InputText extends Vue {
  @Prop({ required: true })
  readonly value!: string
  
  // counterの表示と文字数バリデーションを設定するprops
  @Prop()
  readonly maxLength?: number
  
  // 外からも追加のrulesを渡せるようにする
  @Prop({ default: () => [] })
  readonly rules!: InputValidationRule[]
  
  // maxLengthなど、UIのpropsとバリデーションが紐づくものは追加でrulesを足す
  get finalRules() {
    const finalRules = [...this.rules]
    
    if (this.maxLength != null) {
      finalRules.push(
        (value: string) => value.length <= this.maxLength || `${this.maxLength}文字以下で入力してください`
      )
    }
    
    return finalRules;
  }
}
</script>

react-hook-formでもドキュメントを丁寧に見ていたら実はrulesというpropsを渡せるようで、Vuetifyと同じようなことができそうだったのでこのやり方をまとめました。

https://react-hook-form.com/docs/usecontroller/controller

Controllerにrulesを設定する

ControllerコンポーネントやuseControllerhooksにはrulesというpropsがあり、以下のような設定を行うことができます。requiredmaxLengthなど一部は既に用意されており、設定方法の詳細はドキュメントの方を参照して欲しいですが、基本的には全て自前でバリデーションルールは管理した方が扱いやすいと思うのでvalidateを使うと良いと思います。この記事ではvalidateを使っていきます。

https://react-hook-form.com/docs/useform/register#options

Controllerにrulesを設定する
<Controller
  name="fieldName"
  control={control}
  rules={{
    /* 組み込み済みのルール */
    // required: "入力必須です",
    // maxLength: {
    //   value: 50,
    //   message: "50文字以下で入力してください"
    // },
    /* 自由に設定するバリデーション */
    validate: (value, formValues) => {
      // 好きにバリデーションを設定できる
    },
    // オブジェクト形式で複数のバリデーションを渡せる
    // validate: {
    //   rule1: (value, formValues) => {
    //   
    //   },
    //   rule2: (value, formValues) => {
    //  
    //   },
    // }
  }}
  render={({ field, fieldState }) => {
    // UIと紐付ける
  }}
/>

バリデーションルールセットを別ファイルで定義して設定する

validateで実行するバリデーションメソッドは共通化されていると良いので、別で定義します。

バリデーションルールセット
import { Validate } from "react-hook-form";

// rules/email.ts
export const email: Validate<string, any> = (value) => {
  const pattern = /^[a-zA-Z0-9_.+-]+@([a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]*\.)+[a-zA-Z]{2,}$/;
  return pattern.test(value) || "メールアドレスの形式が合っていません";
};

// rules/maxLength.ts
export const maxLength = (max: number): Validate<string, any> => {
  return (value) => {
    return value.length <= max || `${max}文字以下で入力してください`;
  };
};

// rules/required.ts
export const required: Validate<any, any> = (value) => {
  const errorMessage = "入力必須です";
  if (typeof value === "string") {
    return value.length > 0 || errorMessage;
  }
  return value != null || errorMessage;
};

// 他のルールは省略
バリデーションルールセットをimportして使用する
import * as validators from "~/validators/rules";

<Controller
  rules={{
    validate: {
      // key名はなんでも良さそう
      required: validators.required,
      maxLength: validators.maxLength(50)
    }
  }}
/>

Vuetifyのように配列でバリデーションルールを渡せるようにする

現状のままでも複数ルールのパターンは対応できるので問題はないですが、オブジェクトのままだと無駄なkey名を用意する必要があったり、実行順に不安が出るのでVuetifyと同じように配列で設定できるようにします。そのために以下のようなヘルパーメソッドを用意します。

複数のバリデーションメソッドをマージして一つのバリデーションメソッドにする
import { Validate } from "react-hook-form";

/**
 * 複数のバリデーションを統合して一つのバリデーションメソッドにする
 * @param validators - バリデーションリスト
 */
export const combineValidators = <Value, FieldValues>(
  validators: Validate<Value, FieldValues>[]
): Validate<Value, FieldValues> => {
  return async (value, fieldValues) => {
    for (const validate of validators) {
      const result = await validate(value, fieldValues);
      // エラーメッセージが返ってきたらその時点で返す
      if (typeof result === "string") {
        return result;
      }
    }
    return true;
  };
};

これで以下のように書くことができます。

複数のバリデーションを1つのバリデーションメソッドに統合してから渡す例
 import * as validators from "~/validators/rules";
+import { combineValidators } from "~/validators/helpers";

 <Controller
   rules={{
-    validate: {
-      required: validators.required,
-      maxLength: validators.maxLength(50)
-    }
+    validate: combineValidators([
+      validators.required,
+      validators.maxLength(50)
+    ])
   }}
 />

react-hook-formとUIの紐付けにrulesも組み合わせたコンポーネントを用意して呼び出す

後はVuetifyでやった時と同じようにUIとバリデーションが紐づくルールは内部で作ってしまうコンポーネントを用意して、それを呼び出すようにしていきます。

react-hook-formとテキスト入力UIの紐付けにrulesも組み合わせる
import {
  FieldValues,
  FieldPathByValue,
  Control,
  useController,
  Validate
} from "react-hook-form";
import { useMemo } from "react";

import * as validators from "~/validators/rules";
import { combineValidators } from "~/validators/helpers";
import { InputText } from "~/components";

export type RhfTextWithRuleProps<
  TFieldValues extends FieldValues,
  TName extends FieldPathByValue<TFieldValues, string>
> = {
  name: TName;
  control: Control<TFieldValues>;
  /** 組み込み済みのルール以外で追加するルールリスト */
  additionalRules?: Validate<string, TFieldValues>[];
} & {
  label?: string;
  required?: boolean;
  maxLength?: number;
};

export const RhfTextWithRule = <
  TFieldValues extends FieldValues,
  TName extends FieldPathByValue<TFieldValues, string>
>({
  name,
  control,
  additionalRules,
  label,
  required,
  maxLength
}: RhfTextWithRuleProps<TFieldValues, TName>) => {
  const combinedValidator = useMemo(() => {
    const rules: Validate<string, TFieldValues>[] = [];

    if (required) {
      rules.push(validators.required);
    }

    if (maxLength != null) {
      rules.push(validators.maxLength(maxLength));
    }

    if (additionalRules != null) {
      rules.push(...additionalRules);
    }

    return combineValidators(rules);
  }, [required, maxLength, additionalRules]);

  const { field, fieldState } = useController({
    name,
    control,
    rules: {
      validate: combinedValidator
    }
  });

  return (
    <InputText
      inputRef={field.ref}
      label={label}
      value={field.value}
      required={required}
      counter={maxLength}
      errorMessage={fieldState.error?.message}
      onBlur={field.onBlur}
      onChange={field.onChange}
    />
  );
};

他コンポーネントのコードは長くなるので折りたたんで掲載します。

パスワード入力UI
RhfPasswordWithRule.tsx
import {
  FieldValues,
  FieldPathByValue,
  Control,
  useController,
  Validate
} from "react-hook-form";
import { useMemo } from "react";

import * as validators from "~/validators/rules";
import { combineValidators } from "~/validators/helpers";
import { InputPassword } from "~/components";

export type RhfPasswordWithRuleProps<
  TFieldValues extends FieldValues,
  TName extends FieldPathByValue<TFieldValues, string>
> = {
  name: TName;
  control: Control<TFieldValues>;
  /** 組み込み済みのルール以外で追加するルールリスト */
  additionalRules?: Validate<string, TFieldValues>[];
} & {
  label?: string;
  required?: boolean;
};

export const RhfPasswordWithRule = <
  TFieldValues extends FieldValues,
  TName extends FieldPathByValue<TFieldValues, string>
>({
  name,
  control,
  additionalRules,
  label,
  required
}: RhfPasswordWithRuleProps<TFieldValues, TName>) => {
  const combinedValidator = useMemo(() => {
    const rules: Validate<string, TFieldValues>[] = [validators.password];

    if (required) {
      rules.push(validators.required);
    }

    if (additionalRules != null) {
      rules.push(...additionalRules);
    }

    return combineValidators(rules);
  }, [required, additionalRules]);

  const { field, fieldState } = useController({
    name,
    control,
    rules: {
      validate: combinedValidator
    }
  });

  return (
    <InputPassword
      inputRef={field.ref}
      label={label}
      value={field.value}
      required={required}
      errorMessage={fieldState.error?.message}
      onBlur={field.onBlur}
      onChange={field.onChange}
    />
  );
};
ラジオボタン入力UI
RhfRadioGroupWithRule.tsx
import {
  FieldValues,
  Path,
  PathValue,
  Control,
  useController,
  Validate
} from "react-hook-form";
import { useMemo } from "react";

import * as validators from "~/validators/rules";
import { combineValidators } from "~/validators/helpers";
import { InputRadioGroup } from "~/components";

export type RhfRadioGroupWithRuleProps<
  TFieldValues extends FieldValues,
  TName extends Path<TFieldValues>
> = {
  name: TName;
  control: Control<TFieldValues>;
  /** 組み込み済みのルール以外で追加するルールリスト */
  additionalRules?: Validate<PathValue<TFieldValues, TName>, TFieldValues>[];
} & {
  label?: string;
  required?: boolean;
  options: Array<{ value: PathValue<TFieldValues, TName>; label: string }>;
};

export const RhfRadioGroupWithRule = <
  TFieldValues extends FieldValues,
  TName extends Path<TFieldValues>
>({
  name,
  control,
  additionalRules,
  label,
  required,
  options
}: RhfRadioGroupWithRuleProps<TFieldValues, TName>) => {
  const combinedValidator = useMemo(() => {
    const rules: Validate<PathValue<TFieldValues, TName>, TFieldValues>[] = [];

    if (required) {
      rules.push(validators.required);
    }

    if (additionalRules != null) {
      rules.push(...additionalRules);
    }

    return combineValidators(rules);
  }, [required, additionalRules]);

  const { field, fieldState } = useController({
    name,
    control,
    rules: {
      validate: combinedValidator
    }
  });

  return (
    <InputRadioGroup
      inputRef={field.ref}
      label={label}
      value={field.value}
      options={options}
      required={required}
      errorMessage={fieldState.error?.message}
      onBlur={field.onBlur}
      onChange={field.onChange}
    />
  );
};

これを使って以下のように呼ぶことができ、非常に設定しやすくなりました。一気に書いているので分かりづらいですが、以下がポイントになります。

  • フィールド単位でrulesが設定されるため、非表示になれば自動的にバリデーションも除外される
  • クロスフィールドバリデーションはバリデーションメソッドの第二引数でfieldValuesが貰えるため、そこから参照してカスタムバリデーションを定義できる
ルールと紐づいたコンポーネントを使った入力UIの呼び出し
import { FC } from "react";
import { Stack, Button } from "@mui/material";
import { useForm } from "react-hook-form";

import { JobType, JOB_TYPE_OPTIONS } from "~/constants/Options";
import * as validators from "~/validators/rules";

import {
  RhfTextWithRule,
  RhfRadioGroupWithRule,
  RhfPasswordWithRule
} from "./withRules";

type FormValue = {
  email: string;
  nameKana: string;
  jobType: JobType | null;
  otherJobName: string;
  password: string;
  password2: string;
};

const DEFAULT_FORM_VALUE: FormValue = {
  email: "",
  nameKana: "",
  jobType: null,
  otherJobName: "",
  password: "",
  password2: ""
};

export const UseWithRulesPage: FC = () => {
  const { control, watch, handleSubmit } = useForm<FormValue>({
    defaultValues: DEFAULT_FORM_VALUE
  });

  const watchingJobType = watch("jobType");

  const onSubmit = handleSubmit(
    (data) => {
      console.log("submit", data);
    },
    (err) => {
      console.log("validation error", err);
    }
  );

  return (
    <form noValidate onSubmit={onSubmit}>
      <Stack spacing={2}>
        <RhfTextWithRule
          name="email"
          control={control}
          label="メールアドレス"
          required
          additionalRules={[validators.email]}
        />
        <RhfTextWithRule
          name="nameKana"
          control={control}
          label="フリガナ"
          maxLength={10}
          additionalRules={[validators.katakana]}
        />
        <RhfRadioGroupWithRule
          name="jobType"
          control={control}
          options={JOB_TYPE_OPTIONS}
          label="職業"
          required
        />
        {watchingJobType === "other" && (
          <RhfTextWithRule
            name="otherJobName"
            control={control}
            label="その他の職業"
            required
          />
        )}
        <RhfPasswordWithRule
          name="password"
          control={control}
          label="パスワード"
          required
        />
        <RhfPasswordWithRule
          name="password2"
          control={control}
          label="パスワード(確認)"
          required
          additionalRules={[
            (value, formValue) => {
              return value === formValue.password || "パスワードが一致しません";
            }
          ]}
        />
      </Stack>
      <Button sx={{ mt: 2 }} variant="contained" type="submit">
        送信
      </Button>
    </form>
  );
};

yupもrulesから実行してみる

以上のやり方がrulesでバリデーションする方法でした。バリデーションメソッドも別ファイルで定義すれば共通のルールを使い回すこともできて使い勝手は悪くなさそうですが、yupなどのスキーマバリデーションが使えないことが気になるかもしれません。
rulesのvalidateメソッドは自由に設定できて、そこでyupスキーマのバリデーションを実行することもできます。

yupスキーマをrules.validateメソッドで実行するイメージ
const RhfWithSchema = ({
  name,
  control,
  composeSchema,
  required,
  maxLength
}) => {
  // propsを参照して内部で生成するスキーマ
  const builtInSchema = useMemo(() => {
    let schema = yup.string();

    if (required) {
      schema = schema.required();
    }

    if (maxLength != null) {
      schema = schema.max(maxLength);
    }

    return schema;
  }, [required, maxLength]);
  
  const { field, fieldState } = useController({
    name,
    control,
    rules: {
      validate: async (value, formValues) => {
        // 外から追加のスキーマ設定をする場合はcomposeSchemaを通して最終的なスキーマを取得する
        const finalSchema = composeSchema
          ? composeSchema(builtInSchema, formValues)
          : builtInSchema;

        // スキーマを使ってバリデーションする
        try {
          await finalSchema.validate(value, {
            strict: true,
            abortEarly: true
          });
          return true;
        } catch (err) {
          if (err instanceof yup.ValidationError) {
            return err.message;
          }
          throw err;
        }
      }
    }
  });
  
  return (
    // 入力UIと紐づける
  );
};

上の実装イメージの元に、UIごとのコンポーネントを用意します。

テキスト入力UI
RhfTextWithSchema.tsx
import {
  useController,
  Control,
  FieldValues,
  FieldPathByValue
} from "react-hook-form";
import { useMemo } from "react";

import * as yup from "~/yup";
import { InputText } from "~/components";

export type RhfTextWithSchemaProps<
  TFieldValues extends FieldValues,
  TName extends FieldPathByValue<TFieldValues, string>
> = {
  name: TName;
  control: Control<TFieldValues>;
  /**
   * 組み込み済みのスキーマに追加設定する
   * @param builtInSchema - 組み込み済みのスキーマ
   * @param fieldValues - バリデーション実行時のフォームの値
   */
  composeSchema?: (
    builtInSchema: yup.StringSchema,
    fieldValues: TFieldValues
  ) => yup.StringSchema;
} & {
  label?: string;
  required?: boolean;
  maxLength?: number;
};

export const RhfTextWithSchema = <
  TFieldValues extends FieldValues,
  TName extends FieldPathByValue<TFieldValues, string>
>({
  name,
  control,
  composeSchema,
  label,
  required,
  maxLength
}: RhfTextWithSchemaProps<TFieldValues, TName>) => {
  const builtInSchema = useMemo(() => {
    let schema = yup.string();

    if (required) {
      schema = schema.required();
    }

    if (maxLength != null) {
      schema = schema.max(maxLength);
    }

    return schema;
  }, [required, maxLength]);

  const { field, fieldState } = useController({
    name,
    control,
    rules: {
      validate: async (value, formValues) => {
        const finalSchema = composeSchema
          ? composeSchema(builtInSchema, formValues)
          : builtInSchema;

        try {
          await finalSchema.validate(value, {
            strict: true,
            abortEarly: true
          });
          return true;
        } catch (err) {
          if (err instanceof yup.ValidationError) {
            return err.message;
          }
          throw err;
        }
      }
    }
  });

  return (
    <InputText
      inputRef={field.ref}
      label={label}
      value={field.value}
      required={required}
      counter={maxLength}
      errorMessage={fieldState.error?.message}
      onBlur={field.onBlur}
      onChange={field.onChange}
    />
  );
};
パスワード入力UI
RhfPasswordWithSchema.tsx
import {
  useController,
  Control,
  FieldValues,
  FieldPathByValue
} from "react-hook-form";
import { useMemo } from "react";

import * as yup from "~/yup";
import { InputPassword } from "~/components";

export type RhfPasswordWithSchemaProps<
  TFieldValues extends FieldValues,
  TName extends FieldPathByValue<TFieldValues, string>
> = {
  name: TName;
  control: Control<TFieldValues>;
  /**
   * 組み込み済みのスキーマに追加設定する
   * @param builtInSchema - 組み込み済みのスキーマ
   * @param fieldValues - バリデーション実行時のフォームの値
   */
  composeSchema?: (
    builtInSchema: yup.StringSchema,
    fieldValues: TFieldValues
  ) => yup.StringSchema;
} & {
  label?: string;
  required?: boolean;
};

export const RhfPasswordWithSchema = <
  TFieldValues extends FieldValues,
  TName extends FieldPathByValue<TFieldValues, string>
>({
  control,
  name,
  composeSchema,
  label,
  required
}: RhfPasswordWithSchemaProps<TFieldValues, TName>) => {
  const builtInSchema = useMemo(() => {
    let schema = yup.string().password();

    if (required) {
      schema = schema.required();
    }

    return schema;
  }, [required]);

  const { field, fieldState } = useController({
    name,
    control,
    rules: {
      validate: async (value, formValues) => {
        const finalSchema = composeSchema
          ? composeSchema(builtInSchema, formValues)
          : builtInSchema;

        try {
          await finalSchema.validate(value, {
            strict: true,
            abortEarly: true
          });
          return true;
        } catch (err) {
          if (err instanceof yup.ValidationError) {
            return err.message;
          }
          throw err;
        }
      }
    }
  });

  return (
    <InputPassword
      inputRef={field.ref}
      value={field.value}
      label={label}
      required={required}
      errorMessage={fieldState.error?.message}
      onChange={field.onChange}
      onBlur={field.onBlur}
    />
  );
};
ラジオボタン入力UI
RhfRadioGroupWithSchema.tsx
import {
  useController,
  Control,
  FieldValues,
  Path,
  PathValue,
  FieldPathByValue
} from "react-hook-form";
import { useMemo } from "react";

import * as yup from "~/yup";
import { InputRadioGroup } from "~/components";

export type RhfRadioGroupWithSchemaProps<
  TFieldValues extends FieldValues,
  TName extends Path<TFieldValues>
> = {
  name: TName;
  control: Control<TFieldValues>;
  /**
   * 組み込み済みのスキーマに追加設定する
   * @param builtInSchema - 組み込み済みのスキーマ
   * @param fieldValues - バリデーション実行時のフォームの値
   */
  composeSchema?: (
    builtInSchema: yup.MixedSchema<PathValue<TFieldValues, TName> | undefined>,
    fieldValues: TFieldValues
  ) => yup.MixedSchema<PathValue<TFieldValues, TName> | undefined>;
} & {
  label?: string;
  required?: boolean;
  options: Array<{ value: PathValue<TFieldValues, TName>; label: string }>;
};

export const RhfRadioGroupWithSchema = <
  TFieldValues extends FieldValues,
  TName extends Path<TFieldValues>
>({
  name,
  control,
  composeSchema,
  label,
  required,
  options
}: RhfRadioGroupWithSchemaProps<TFieldValues, TName>) => {
  const builtInSchema = useMemo(() => {
    let schema = yup
      .mixed<PathValue<TFieldValues, TName>>()
      .oneOf(options.map((opt) => opt.value));

    if (required) {
      schema = schema.required();
    }

    return schema;
  }, [options, required]);

  const { field, fieldState } = useController({
    name,
    control,
    rules: {
      validate: async (value, formValues) => {
        const finalSchema = composeSchema
          ? composeSchema(builtInSchema, formValues)
          : builtInSchema;

        try {
          await finalSchema.validate(value, {
            strict: true,
            abortEarly: true
          });
          return true;
        } catch (err) {
          if (err instanceof yup.ValidationError) {
            return err.message;
          }
          throw err;
        }
      }
    }
  });

  return (
    <InputRadioGroup
      inputRef={field.ref}
      label={label}
      value={field.value}
      options={options}
      required={required}
      errorMessage={fieldState.error?.message}
      onBlur={field.onBlur}
      onChange={field.onChange}
    />
  );
};

これらのコンポーネントを使うと以下のように書くことができます。使い勝手はrulesを組み込んだコンポーネントの時とほぼ同じになります。

フィールド単位のスキーマと紐づいたコンポーネントを使った入力UIの呼び出し
import { FC } from "react";
import { Stack, Button } from "@mui/material";
import { useForm } from "react-hook-form";

import { JOB_TYPE_OPTIONS, JobType } from "~/constants/Options";
import {
  RhfTextWithSchema,
  RhfRadioGroupWithSchema,
  RhfPasswordWithSchema
} from "./withSchema";

type FormValue = {
  email: string;
  nameKana: string;
  jobType: JobType | null;
  otherJobName: string;
  password: string;
  password2: string;
};

const DEFAULT_FORM_VALUE: FormValue = {
  email: "",
  nameKana: "",
  jobType: null,
  otherJobName: "",
  password: "",
  password2: ""
};

export const FieldSchemaPage: FC = () => {
  const { control, watch, handleSubmit } = useForm<FormValue>({
    defaultValues: DEFAULT_FORM_VALUE
  });

  const watchingJobType = watch("jobType");

  const onSubmit = handleSubmit(
    (data) => {
      console.log("submit", data);
    },
    (err) => {
      console.log("validation error", err);
    }
  );

  return (
    <form noValidate onSubmit={onSubmit}>
      <Stack spacing={2}>
        <RhfTextWithSchema
          name="email"
          control={control}
          label="メールアドレス"
          required
          composeSchema={(schema) => schema.email()}
        />
        <RhfTextWithSchema
          name="nameKana"
          control={control}
          label="フリガナ"
          maxLength={10}
          composeSchema={(schema) => schema.katakana()}
        />
        <RhfRadioGroupWithSchema
          name="jobType"
          control={control}
          label="職業"
          options={JOB_TYPE_OPTIONS}
          required
        />
        {watchingJobType === "other" && (
          <RhfTextWithSchema
            name="otherJobName"
            control={control}
            label="その他の職業"
            required
          />
        )}
        <RhfPasswordWithSchema
          name="password"
          control={control}
          label="パスワード"
          required
        />
        <RhfPasswordWithSchema
          name="password2"
          control={control}
          label="パスワード(確認)"
          required
          composeSchema={(schema, formValues) => {
            return schema.oneOf([formValues.password], "パスワードが一致しません");
          }}
        />
      </Stack>
      <Button sx={{ mt: 2 }} variant="contained" type="submit">
        送信
      </Button>
    </form>
  );
};

検証コード

今回ここで書いたサンプルコードは以下のCodeSandboxに上がっていますので、詳細のコードや動きを見たい方はご覧ください。

終わりに

以上がreact-hook-formのバリデーションをrulesで行う方法でした。フィールド単位でバリデーションルールを設定できるようになったことでUIと関係するパラメータは自動でバリデーションにも組み込むことができるため、UIとバリデーションのチグハグが起こる可能性が無くなって良いなと思いました。ただ僕がVuetifyでrulesを組み込んでいた時にも言われたのですが、どれがバリデーションとして設定されるのかが分かりづらいという意見もあり、デメリットもあるなと思いました。加えて、react-hook-formでこれをやると全然記事が見当たらないので、詰まってしまった時に困るかもしれないです。
どのやり方も一長一短あると思いますが、スキーマからバリデーションするのが辛い人の参考になれたら幸いです。

Discussion