※この記事は、 CyberAgent PTA Advent Calendar 2021の20日目の記事です。
現在サイバーエージェントのメディア事業部でエンジニアをやっているwata3110です。
この記事はReactのフォームをformikを使って作成した時に工夫したことについてまとめます。
[Formik] (https://formik.org/docs/tutorial)はreactのフォーム作成用ライブラリです。
Formikでフォームを作成する際、[Yup] (https://formik.org/docs/guides/validation)を使って作成することが多いと思いますが、この記事ではこちらを使わずにフォームを作成してみたときのことについてまとめてみました。
Formikとは
- フォーム状態の管理
- エラーハンドリング
- バリデーション
- フォーム送信の処理
などを1つの場所に配置し、管理することで、テスト、リファクタリング、修正などを簡単に行えるようにします。
Yupとは
formik公式ドキュメントでも推奨されているバリデーションライブラリで,
フォームの入力値を解析してバリデーションを行うために、JavaScriptでスキーマ(データ構造)を定義します。
Yupを使ったフォーム
簡単なフォームの例を用いて、yupを使ったフォームについて説明します。
まずFormikの引数にinitialValues(初期値)、onSubmit(Submit時の関数)とvalidationSchemaを渡します。
このvalidationSchemaにフォームの入力値を解析してバリデーションを行うためのデータ構造のYupオブジェクトを渡します。
import React from "react";
import { useFormik } from "formik";
import * as Yup from "yup";
type SampleFormValue = {
userName: string;
email: string;
};
const SampleFormValidationSchema = Yup.object<SampleFormValue>({
userName: Yup.string()
.max(10, "名前は10文字以下に設定してください")
.required("必須です"),
email: Yup.string()
.email("メールアドレス形式で入力してください")
.required("必須です"),
});
export const SampleForm: React.FC = () => {
const formik = useFormik<SampleFormValue>({
initialValues: {
userName: "",
email: "",
},
validateSchema: SampleFormValidationSchema,
onSubmit: (values) => {
console.log(values);
},
});
return (
<>
<h1>{"タイトル"}</h1>
<form onSubmit={formik.handleSubmit}>
<label>ユーザー名</label>
<input
id="userName"
name="userName"
type="text"
onChange={formik.handleChange}
onBlur={formik.handleBlur}
value={formik.values.userName}
/>
{formik.errors.userName && <div>{formik.errors.userName}</div>}
<label>メールアドレス</label>
<input
id="email"
name="email"
type="email"
onChange={formik.handleChange}
onBlur={formik.handleBlur}
value={formik.values.email}
/>
{formik.errors.email && <div>{formik.errors.email}</div>}
<button type="submit">Submit</button>
</form>
</>
);
};
といった形で簡単にバリデーションが実装できます。
シンプルなフォームであればYupはとても便利だと思います。
ただformの型がネストしてる場合や自分で定義した型を持っている場合、書き方が少しややこしくなります。
↓こちら先程の例のフォームにinfo
という独自で定義した型を持つkeyを追加しました。
type Userinfo = {
isPublic: boolean;
age?: number;
};
type SampleFormValue = {
userName: string;
email: string;
info: Userinfo;
};
const SampleFormValidationSchema = Yup.object<SampleFormValue>().shape({
userName: Yup.string().required("必須です"),
email: Yup.string().required("必須です"),
info: Yup.object<Userinfo>().test(
"info",
"年齢は必須です",
(value) => !!value && value.isPublic && !(value?.age === undefined)
),
});
isPublic
の値がtrue
だった時、age
の値を必須にするというバリデーションをかけています。
Yupで実装してみて感じることは
シンプルなバリデーションに関しては、ものすごく簡単なんですが、バリデーションのルールが少し複雑になる、またはkeyが独自の型や独自の型の配列だったりすると結構めんどくさいケースが多い印象です。(特に型の整合性が取りにくい)
しかしここで一番の問題はinfo
が型を持ってないという点です。
const SampleFormValidationSchema = Yup.object<SampleFormValue>().shape({
userName: Yup.string().required("必須です"),
email: Yup.string().required("必須です"),
info: Yup.number().required("必須です"),
});
formのinfo
の型は独自のオブジェクト型なんですが、Schema側のinfo
の型はバリデーションのルールによった型になってしまい、上記のような書き方でもコンパイルが通ってしまいます。
実現したいこと
- Formの値を受け取って、エラーメッセージを返すカスタムバリーデーター関数を作り、その関数にvalidationSchemaを投げれば、簡単にバリデーションが実装できる仕組みを作る。
- Yupを使うより保守性を上げたい。
そのためにまずformの型に合わせたvalidationSchemaの型を定義します。
export type ValidationSchemaType<T> = {
[K in keyof T]: NonNullable<T[K]> extends (infer R)[]
? (value: R[]) => string | undefined
: NonNullable<T[K]> extends object
? ((value: T[K]) => string | undefined) | ValidationSchemaType<T[K]>
: (value: T[K]) => string | undefined;
};
Formのkeyが配列型であれば
そのkeyの型の配列を受け取り、string | undefined
を返す関数、
keyがオブジェクト型であれば、keyの型のオブジェクトを受け取り、string | undefined
を返す関数か、再帰した結果になり,keyが配列かObject以外の型であれば、keyの型を受け取り、string | undefinedを返す関数をkeyに持つvalidationSchemaになるような型を定義します。
keyがオブジェクト型であったときに、二通りの型を定義しているのは、
オブジェクト毎でバリデーションをかけたい場合と、オブジェクトのkey毎でバリデーションをかけた場合でも型を保持できるようにしたいためです。
//オブジェクト全体でバリデーションをかけたい場合
const SampleFormValidation: ValidationSchemaType<SampleFormValue> = {
// valueの型はstring
userName: (value) => {
return value === undefined ? "必須です" : undefined;
},
// valueの型はstring
email: (value) => {
return value === undefined ? "必須です" : undefined;
},
// valueの型はUserInfo
info: (value) => {
return value.isPublic && value.age === undefined
? "公開モードにしてる場合は年齢は必須です"
: undefined;
},
};
//オブジェクトのkey毎でバリデーションをかけたい場合
const SampleFormValidation: ValidationSchemaType<SampleFormValue> = {
userName: (value) => {
return value === undefined ? "必須です" : undefined;
},
email: (value) => {
return value === undefined ? "必須です" : undefined;
},
info: {
// valueの型はboolean
isPublic: (value) => (value === undefined ? " 必須です" : undefined),
// valueの型はnumber
age: (value) => (value === undefined ? "必須です" : undefined),
},
};
次にFormの値を受け取って、エラーメッセージを返すカスタムバリーデーター関数を定義します。
export const createValidationSchema = <T>(schema: ValidationSchemaType<T>) => (
value: T
) => {
const flattenSchema: FlatValidationSchema<T> = flatten(schema);
var errors: FlatValidationErrors<T> = {};
_.forEach(
Object.keys(flattenSchema) as (keyof FlatValidationSchema<T>)[],
(key) => {
errors[key] = flattenSchema[key]
? flattenSchema[key](_.get(value, key))
: undefined;
}
);
return removeUndefinedFromObject(errors) as FlatValidationErrors<T>;
};
// undefinedは取り除く
const removeUndefinedFromObject = (obj: object) =>
Object.fromEntries(Object.entries(obj).filter(([k, v]) => v !== undefined));
引数で受け取ったschemaをフラットにして、schemaのkeyが一致するformの値をチェックします。
この関数はフォームを受け取ってエラーを返す関数を返します。
export const check =
<T>(...checker: ((value: T) => string | undefined)[]) =>
(value: T) => {
const c = _.find(checker, (check) => check(value));
return c ? c(value) : undefined;
};
複数バリデーションの条件を追加したい場合のcheck関数を定義します。
こちらを使えば複数条件でバリデーションをかけることができます。
const required = (value?: string) => (!value ? "必須です" : undefined);
const mailReg =
/^[A-Za-z0-9]{1}[A-Za-z0-9_.-]*@{1}[A-Za-z0-9_.-]{1,}\.[A-Za-z0-9]{1,}$/;
const mailAddress = (value?: string) =>
value && !mailReg.test(value)
? "メールアドレス形式で入力してください。"
: undefined;
const checkInfo = (value?: Userinfo) =>
value.isPublic && value.age === undefined
? "公開状態にしてる場合は年齢は必須です"
: undefined;
const formik = useFormik<SampleFormValue>({
initialValues: {
userName: "",
email: "",
info: {
isPublic: false,
},
},
validate: createValidationSchema({
userName: required,
email: check(required, mailAddress),
info: checkInfo,
}),
onSubmit: (values) => {
console.log(values);
},
});
作成したvalidationSchemaをformikに渡せば、完了です。
オブジェクト毎でバリデーションする際の実装がYupで書くよりすごく楽になり、
validationSchemaの型とFormの型の整合性を保持できるようになりました。
まとめ
シンプルなformに関してはYupで良さそうだが、細かいバリデーションルールがある場合は今回の実装の方が使いやすいなと感じました。まだ無理やりやってる感が強いところもあるので、もう少し使いやすいようにしていきたいです。今回の実装を通してTypeScriptの知見が増えてよかったと思いました。今後より良くしていきたいです。もう少し汎用的に使えるようにしてライブラリ化できたらと思います。
参考文献
[Formikドキュメント] (https://formik.org/docs/tutorial)
Typescriptドキュメント
[フォームのバリデーションに役立つYupとは? React向けライブラリを解説] (https://codezine.jp/article/detail/13518)