react-hook-formのバリデーションはrulesを使った方が楽かもしれない
始めに
Reactでフォームを扱う場合はreact-hook-formがよく使われますが、このライブラリでバリデーションをする場合、スキーマを作ってバリデーションをすることが多いと思います。
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の設定のずれは導入前から懸念しており、スキーマから必要なパラメータを取得できないかは事前に調べておりそれで値のずれは解消されそうですが、それでもそもそも設定すること自体忘れてしまう可能性が残ってしまいます。
僕は元々Vuetifyを使っていたこともあり、以下みたいにフィールド単位で直接rulesを設定して、UIに関連するバリデーションは自動で設定するようにしたら上記のような設定漏れの懸念がそもそも起こらないのになぁと思っていました。
<!-- 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と同じようなことができそうだったのでこのやり方をまとめました。
Controllerにrulesを設定する
Controller
コンポーネントやuseController
hooksにはrules
というpropsがあり、以下のような設定を行うことができます。required
やmaxLength
など一部は既に用意されており、設定方法の詳細はドキュメントの方を参照して欲しいですが、基本的には全て自前でバリデーションルールは管理した方が扱いやすいと思うのでvalidate
を使うと良いと思います。この記事ではvalidate
を使っていきます。
<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 * 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;
};
};
これで以下のように書くことができます。
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とバリデーションが紐づくルールは内部で作ってしまうコンポーネントを用意して、それを呼び出すようにしていきます。
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
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
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が貰えるため、そこから参照してカスタムバリデーションを定義できる
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スキーマのバリデーションを実行することもできます。
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
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
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
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を組み込んだコンポーネントの時とほぼ同じになります。
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