はじめに
vivit株式会社でフロントエンドエンジニアをしている氏家です。 私は現在、アウトドア用品の中古品買取と販売を行うhinataリユースの内製システムの開発に携わっています。
システムはNext.js + TypeScriptにUIコンポーネントライブラリのAnt Designを導入して開発されています。 このシステムに買取商品の情報を入力するフォームを実装する際、フォーム周りのステートやロジックをAnt Designに持たせたくないという意向があったので、React Hook Formを導入しフォームのステート管理やバリデーションを任せることにしました。
このReact Hook Form、yupを使用したスキーマバリデーションをサポートしていたり、UIコンポーネントライブラリが提供する制御されたコンポーネントにも対応していたりと、使い勝手が非常に良く、公式ドキュメントも充実しているためスムーズに開発を進めることができました。
今回はそんなReact Hook Formの基本的な使い方と、実際に開発しているうえで手こずった点をサンプルコードと一緒に紹介できればと思います。
環境
今回使用した環境は以下の通りです。
- Next.js v11.1.2
- TypeScript v4.4.3
- React Hook Form v7.17.1
- Ant Design v4.16.13
本記事に乗せているサンプルコードは全て上記の環境で動いています。
基本的な使い方
まずはReact Hook Formの基本的な使い方から見ていきたいと思います。
はじめに、下記のようなフォームを用意します。
- src/pages/index.tsx import type { NextPage } from "next"; const Home: NextPage = () => { return ( <div style={{ textAlign: "center", marginTop: "100px" }}> <h1>フォーム</h1> <hr /> <form> <div> <label> 名前: <input /> </label> </div> <div> 性別: <input type="radio" id="male" value="male" /> <label htmlFor="male">男</label> <input type="radio" id="female" value="female" /> <label htmlFor="female">女</label> <input type="radio" id="other" value="other" /> <label htmlFor="other">その他</label> </div> <div> <label> 年齢: <input />歳 </label> </div> <hr /> <input type="submit" /> </form> </div> ); }; export default Home;
↓ブラウザ画面
このままでは送信ボタンを押しても何も起きないので、React Hook Formでフォームの設定をしていきます。
yarn add react-hook-form
でReact Hook Formを追加し、以下の通り変更します。
- src/pages/index.tsx import type { NextPage } from "next"; import { useState } from "react"; import { useForm, SubmitHandler } from "react-hook-form"; type FormValues = { name: string; gender: string; age: number; }; const Home: NextPage = () => { const { register, handleSubmit } = useForm<FormValues>(); const [submitData, setSubmitData] = useState({ name: null, gender: null, age: null, }); const onSubmit: SubmitHandler<FormValues> = (data) => setSubmitData(data); return ( <div style={{ textAlign: "center", marginTop: "100px" }}> <h1>フォーム</h1> <hr /> <form onSubmit={handleSubmit(onSubmit)}> <div> <label> 名前: <input {...register("name")} /> </label> </div> <div> 性別: <input type="radio" id="male" value="男" {...register("gender")} /> <label htmlFor="male">男</label> <input type="radio" id="female" value="女" {...register("gender")} /> <label htmlFor="female">女</label> <input type="radio" id="other" value="その他" {...register("gender")} /> <label htmlFor="other">その他</label> </div> <div> <label> 年齢: <input {...register("age")} />歳 </label> </div> <hr /> <input type="submit" /> <hr /> <p>名前: {submitData.name ?? submitData.name}</p> <p>性別: {submitData.gender ?? submitData.gender}</p> <p>年齢: {submitData.age ?? submitData.age}歳</p> </form> </div> ); }; export default Home;
React Hook Formの使い方は非常にシンプルで、各項目をregister()
で登録するだけです。その際、一意になるようなname属性を渡す必要があります。
ラジオボタンの場合は同じname属性を持つ要素同士でラジオグループを形成するので、各項目に同じ値を設定します。
送信ボタンを押下するとonSubmit()
というコールバック関数が呼ばれ、ページ下部に入力された値が表示されます。
ステートを一元管理してくれるので、コードがスッキリしてみえます。
バリデーションの設定
HTML 標準のフォームバリデーションをサポートしており、簡単に設定することができます。
<input type="number" {...register("age", { min: 0, max: 99 })} />
しかし、各項目に直接バリデーションを設定すると、フォームの項目が増えるにつれてコード量も増え、管理が大変になってしまいます。
React Hook Formではyupを使用したスキーマバリデーションをサポートしており、膨れがちなフォーム部分からバリデーションの処理を切り離し、スキーマで管理することができます。
まずyarn add @hookform/resolvers yup
でyupを追加します。
スキーマは以下のように定義します。
import * as yup from "yup"; import { yupResolver } from "@hookform/resolvers/yup"; const schema = yup.object().shape({ name: yup.string().required(), gender: yup.string().required(), age: yup.number().positive().integer().required(), });
あとはリゾルバーにスキーマを渡すだけで、バリデーションのエラーメッセージをerrors
から参照することができます。
const { register, handleSubmit, formState:{ errors } } = useForm<FormValues>({ resolver: yupResolver(schema) });
さきほどのフォームにバリデーションを適用し、最終的に以下のようになりました。
- src/pages/index.tsx import type { NextPage } from "next"; import { useState } from "react"; import { useForm, SubmitHandler } from "react-hook-form"; import * as yup from "yup"; import { yupResolver } from "@hookform/resolvers/yup"; type FormValues = { name: string; gender: string; age: number; }; const schema = yup.object().shape({ name: yup.string().required(), gender: yup.string().required(), age: yup.number().positive().integer().required(), }); const Home: NextPage = () => { const { register, handleSubmit, formState: { errors }, } = useForm<FormValues>({ resolver: yupResolver(schema), }); const [submitData, setSubmitData] = useState({ name: null, gender: null, age: null, }); const onSubmit: SubmitHandler<FormValues> = (data) => setSubmitData(data); return ( <div style={{ textAlign: "center", marginTop: "100px" }}> <h1>フォーム</h1> <hr /> <form onSubmit={handleSubmit(onSubmit)}> <div> <label> 名前: <input {...register("name")} /> </label> <p>{errors.name?.message}</p> </div> <div> 性別: <input type="radio" id="male" value="男" {...register("gender")} /> <label htmlFor="male">男</label> <input type="radio" id="female" value="女" {...register("gender")} /> <label htmlFor="female">女</label> <input type="radio" id="other" value="その他" {...register("gender")} /> <label htmlFor="other">その他</label> <p>{errors.gender?.message}</p> </div> <div> <label> 年齢: <input {...register("age")} />歳 </label> <p>{errors.age?.message}</p> </div> <hr /> <input type="submit" /> </form> <hr /> <p>名前: {submitData.name ?? submitData.name}</p> <p>性別: {submitData.gender ?? submitData.gender}</p> <p>年齢: {submitData.age ?? submitData.age}歳</p> </div> ); }; export default Home;
↓フォームに不備があるとエラーメッセージが表示される
ネストしたフォームの設定
フォーム部分をコンポーネントとして切り出したいときがあると思いますが、フォームがネストしていると都度propsをコンポーネントに渡す必要があり面倒だと思うかもしれません。
しかしReact Hook FormではuseFormContext()
を使用することで簡単に実装することができます。
- src/pages/nest/index.tsx import type { NextPage } from "next"; import { useState } from "react"; import { useForm, FormProvider, SubmitHandler } from "react-hook-form"; import * as yup from "yup"; import { yupResolver } from "@hookform/resolvers/yup"; import { Form1 } from "../../components/Form1"; import { Form2 } from "../../components/Form2"; type FormValues = { name: string; gender: string; age: number; favoriteFood: string; favoriteEvenNumber: Array<number>; }; const schema = yup.object().shape({ name: yup.string().required(), gender: yup.string().required(), age: yup.number().positive().integer().required(), favoriteFood: yup.string().nullable(), favoriteEvenNumber: yup .array() .min(1) .transform((value) => value ?? []), }); const Nest: NextPage = () => { const methods = useForm<FormValues>({ resolver: yupResolver(schema), }); const [submitData, setSubmitData] = useState({ name: null, gender: null, age: null, favoriteFood: null, favoriteEvenNumber: [], }); const onSubmit: SubmitHandler<FormValues> = (data) => { console.log(data); setSubmitData(data); }; return ( <div style={{ textAlign: "center", marginTop: "100px" }}> <h1>フォーム</h1> <hr /> <FormProvider {...methods}> <form onSubmit={methods.handleSubmit(onSubmit)}> <Form1 /> <hr /> <Form2 /> <hr /> <input type="submit" /> </form> </FormProvider> <hr /> <p>名前: {submitData.name ?? submitData.name}</p> <p>性別: {submitData.gender ?? submitData.gender}</p> <p>年齢: {submitData.age ?? submitData.age}歳</p> <p>好きな食べ物: {submitData.favoriteFood ?? submitData.favoriteFood}</p> <p> 好きな偶数: {submitData.favoriteEvenNumber && submitData.favoriteEvenNumber.map((value) => `${value} `)} </p> </div> ); }; export default Nest;
フォームを含むコンポーネントをFormProvider
でラップし、各コンポーネントでuseFormContext()
を使用します。
- src/components/Form1/index.tsx import { useFormContext } from "react-hook-form"; export const Form1 = () => { const { register, formState: { errors }, } = useFormContext(); return ( <div> <div> <label> 名前: <input {...register("name")} /> </label> <p>{errors.name?.message}</p> </div> <div> 性別: <input type="radio" id="male" value="男" {...register("gender")} /> <label htmlFor="male">男</label> <input type="radio" id="female" value="女" {...register("gender")} /> <label htmlFor="female">女</label> <input type="radio" id="other" value="その他" {...register("gender")} /> <label htmlFor="other">その他</label> <p>{errors.gender?.message}</p> </div> <div> <label> 年齢: <input {...register("age")} />歳 </label> <p>{errors.age?.message}</p> </div> </div> ); };
- src/components/Form2/index.tsx import { useFormContext } from "react-hook-form"; export const Form2 = () => { const { register, formState: { errors }, } = useFormContext(); return ( <div> <div> <label> 好きな食べ物(任意): <input {...register("favoriteFood")} /> </label> <p>{errors.favoriteFood?.message}</p> </div> <div> 好きな偶数(最低1つ) {[...Array(11)].map( (_, i) => !(i % 2) && ( <span key={i}> <input type="checkbox" value={i} {...register("favoriteEvenNumber")} /> {i} </span> ) )} <p>{errors.favoriteEvenNumber?.message}</p> </div> </div> ); };
このままだとバリデーションの情報を呼び出し元であるsrc/page/nest/index.tsx
が持ってしまっているので、各コンポーネントに閉じたいと思います。
- src/components/Form1/index.tsx import { useFormContext } from "react-hook-form"; import * as yup from "yup"; export const Form1Validation = { name: yup.string().required(), gender: yup.string().required(), age: yup.number().positive().integer().required(), }; export const Form1 = () => { const { register, formState: { errors }, } = useFormContext(); return ( ...
- src/components/Form2/index.tsx import { useFormContext } from "react-hook-form"; import * as yup from "yup"; export const Form2Validation = { favoriteFood: yup.string().nullable(), favoriteEvenNumber: yup .array() .min(1) .transform((value) => value ?? []), }; export const Form2 = () => { const { register, formState: { errors }, } = useFormContext(); return (
あとはページ側でインポートして設定するだけです。
const schema = yup.object().shape({ ...Form1Validation, ...Form2Validation, });
コンポーネントがどこで呼び出されるのか気にする必要がないため、フォームを再利用できるというメリットもあります。
↓ブラウザ画面
UIコンポーネントライブラリを使用する
React Hook FormはAnt DesignやMaterial UIなどの制御されたコンポーネントに対しても設定することができます。
ドキュメントでは2つのやり方が紹介されていますが、今回はAnt DesignのコンポーネントにController
を使用するやり方で実装していきます。
- src/components/AntForm1/index.tsx import * as Ant from "antd"; import { useFormContext, Controller } from "react-hook-form"; import * as yup from "yup"; export const AntForm1Validation = { name: yup.string().required(), gender: yup.string().required(), age: yup.number().positive().integer().required(), }; export const AntForm1 = () => { const { control, formState: { errors }, } = useFormContext(); return ( <> <Controller control={control} name="name" render={({ field, fieldState }) => ( <Ant.Form.Item label="名前" validateStatus={fieldState.error ? "error" : ""} help={errors.name?.message} required > <Ant.Input {...field} /> </Ant.Form.Item> )} /> <Controller control={control} name="gender" render={({ field, fieldState }) => ( <Ant.Form.Item label="性別" validateStatus={fieldState.error ? "error" : ""} help={errors.gender?.message} required > <Ant.Radio.Group {...field}> <Ant.Radio value="male">男</Ant.Radio> <Ant.Radio value="female">女</Ant.Radio> <Ant.Radio value="other">その他</Ant.Radio> </Ant.Radio.Group> </Ant.Form.Item> )} /> <Controller control={control} name="age" render={({ field, fieldState }) => ( <Ant.Form.Item label="年齢" validateStatus={fieldState.error ? "error" : ""} help={errors.age?.message} required > <Ant.Input {...field} /> </Ant.Form.Item> )} /> </> ); };
先ほど同様、フォームをコンポーネントに切り出しています。
Controller
コンポーネントのrender
に制御されたコンポーネントを渡すかたちで使用します。
(初期値はdefaultValue
に渡すことで設定できます)
↓ブラウザ画面
手こずった点
紹介するのはどちらもyupによるバリデーションの設定についてです。
ドキュメントを読んでも分かりづらい内容だったので共有させていただきます。
バリデーションエラーのメッセージ変更
エラーメッセージの変更には以下の2つの方法があります。
yup.setLocale()
で各バリデーションごとのメッセージを定義する- バリデーション関数の引数に渡す
1つ目はyup
のデフォルトメッセージを変更する方法です。
yup.jp.ts import * as yup from 'yup'; yup.setLocale({ mixed: { default: '不正な入力です', required: '入力必須項目です', }, ..... }); export default yup;
使用する際は以下のように、独自に設定したものをインポートするようにします。
import yup from "path/to/yup.jp.ts";
しかし、例えば数字のみの入力欄に文字を入れたときのエラーメッセージ(型エラーによるメッセージ)を「半角数字のみ入力してください」と変更したくても、それを設定するためのフィールドが用意されていないため変更することはできません。
この場合、2つ目の「バリデーション関数の引数にメッセージを渡す」という方法を取る必要があります。
yup.number().typeError("半角数字のみ入力してください").required("入力必須項目です")
他にもいくつかyup.setLocale()
では変更できないメッセージがあるので、変更が必要なときは各関数に直接設定するようにします。
カスタムバリデーション
yupが用意しているバリデーションの他に、独自のバリデーションを設定したい場合があります。
これは.test()
メソッドを使用することで実装することができます。
yup.number().test("6以外は入力しないでください", (value) => { return value === 6; })
新たなバリデーション関数をaddMethod()
で定義することもできます。
yup.addMethod<yup.NumberSchema>( yup.number, "isSix", function () { return this.test( "isSix", "6以外は入力しないでください", function (value) { return value === 6; } ); } );
以下のように、定義した型でバリデーション関数を使用できます。
yup.number().isSix()
しかしこのままだと「isSix()
はnumber()
に存在しません」と型エラーが出てしまうので、yup.BaseSchema
を拡張するかたちで型を追加します。
import { AnyObject, Maybe } from "yup/lib/types"; declare module "yup" { interface NumberSchema< TType extends Maybe<number> = number | undefined, TContext extends AnyObject = AnyObject, TOut extends TType = TType > extends yup.BaseSchema<TType, TContext, TOut> { // ここに追加していく isSix(): NumberSchema<TType, TContext>; } }
複数回使用される可能性のあるカスタムバリデーションはyup.addMethod()
で都度作成するのが良さそうです。
まとめ
React Hook Form + yup の使い方について簡単にまとめます。
- 基本的にはフォームの各項目を
register()
で登録するだけ - yupを使用することでバリデーションをスキーマで定義して設定できる
- ネストしたフォームを使用する場合は
useFormContext()
を使用する - UIライブラリなどが提供する、制御されたコンポーネントを使用する場合は
Controller
を使用すると楽 - バリデーションエラーのメッセージを変更する場合は
yup.setLocale()
で設定するか、バリデーション関数の引数に直接渡す - カスタムバリデーションの設定は
.test()
メソッドに直接ロジックを書くか、yup.addMethod()
で作成した独自関数を使用する
個人的にはフォームがネストしている際にuseFormContext()
1つで解決できる点と、React Hook Form + UIコンポーネントライブラリの使用に別途何かを設定する必要がないという点にとても魅力を感じました。
特に後者は、違うUIコンポーネントライブラリを使用することになったり、自前でスタイルを書くことになった場合でも簡単に書き換えることができます。
例えばAnt Designのフォームは、送信ボタン押下時にバリデーションエラーがあった場合その項目の位置まで自動でスクロールしたり、バリデーションエラーがある入力欄を赤く表示したりすることができるのですが、これらのリッチなUIをAnt Designのフォームにステートを持たせることなく使用することができます。
<Controller control={control} name="name" render={({ field, fieldState }) => ( <Ant.Form.Item label="名前" // fieldStateからinputの状態を切り替える validateStatus={fieldState.error ? "error" : ""} // formState.errorsの値の有無でエラーメッセージを動的に出し分ける help={errors.name?.message} required > <Ant.Input {...field} /> </Ant.Form.Item> )} />
各ライブラリの使いたい機能だけを互いに依存することなく使うことができるというのは、技術的負債になりうる要因を減らすという点でも重要だと感じました。
おわりに
「基本的な使い方」の項で記載したサンプルコードはGitHubでも公開しています。 ローカルでぽちぽち触っていただくとより分かりやすいかもしれません。
https://github.com/ujike-ryota/RHF-yup-example
vivitではアウトドアwebサービスをともに成長させる仲間を募集しています。
下記からご確認ください。