本記事は
WebアプリWeek
1日目の記事です。
🌊
イベント告知
▶▶ 本記事 ▶▶
2日目
💻
はじめに
はじめまして、NTシステム事業部の法地です!
今年の4月頃からReact(TypeScript)でWeb開発に携わるようになり、React Hook FormとYupを使ったフォームの実装方法についてある程度理解できてきたので、備忘のためにブログにします。
以下のフォーム画面を実装していきます。
React Hook Formとは
React Hook Formとは、入力フォームのデータをまとめて簡単に扱えるReact向けのライブラリです。
input要素などに入力した値の取得やバリデーション機能なども備えており、簡単にフォームを実装することができます。
Yupとは
Yupとは、バリデーションライブラリです。
React Hook Formが標準で対応しているライブラリであるため、今回採用しています。
使用するライブラリ
- Home | React Hook Form - Simple React forms validation
- GitHub - jquense/yup: Dead simple Object schema validation
- Bootstrap · 世界で最も人気のあるフロントエンドフレームワーク
スキーマ定義
まずスキーマを定義する前に、フォーム用の型を定義します。
export type FormModel = { name: string; email?: string; password: string; address?: typeof Address[number]; gender: typeof Gender[number]; isMailMagazine: boolean; }; export const Address = ['tokyo', 'osaka', ''] as const; // ''は未選択状態 export const Gender = ['male', 'female', 'other'] as const;
次にYupでフォーム用のスキーマを定義します。
スキーマにyup.SchemaOf
の型を定義することで、コーディング時に型チェックしてくれるので便利です。
バリデーションエラー時のメッセージを定義することも可能です。
import * as yup from 'yup'; import { FormModel, Address, Gender } from './types'; export const schema: yup.SchemaOf<FormModel> = yup.object().shape({ name: yup.string().required('名前を入力してください'), email: yup.string().email('有効なメールアドレスを入力してください'), password: yup.string().required('パスワードを入力してください'), address: yup.mixed().oneOf(Address.concat([])), gender: yup.mixed().oneOf(Gender.concat([])).required('性別を選択してください'), isMailMagazine: yup.boolean().required(), }); export type Form = yup.InferType<typeof schema>;
コンポーネント
フォームで使用するコンポーネントを実装します。
React Hook Formに依存するコンポーネントとそうでないコンポーネントを分けて、使い分けできるようにしています。
また、register
が使用できない(refへのアクセスができない)外部UIライブラリを採用する可能性を考慮して、Controller
を使用して実装しています。
テキストボックス
コンポーネント
import React from 'react'; type Props = { errorMassage?: string; } & React.ComponentProps<'input'>; export const Input = React.forwardRef<HTMLInputElement, Props>(({ errorMassage, ...props }, ref) => ( <> <input className="form-control" ref={ref} {...props} /> {errorMassage ? <div className="text-danger">{errorMassage}</div> : null} </> ));
import React from 'react'; import { Controller, FieldPath, ControllerRenderProps } from 'react-hook-form'; import { Input } from './Input'; type Props<T> = { fieldName: FieldPath<T>; } & Omit<React.ComponentProps<typeof Input>, 'errorMassage' | keyof ControllerRenderProps>; // 重複するプロパティを除外する export function InputControl<T>({ fieldName, ...props }: Props<T>) { return ( <Controller name={fieldName} render={({ field, fieldState }) => <Input errorMassage={fieldState.error?.message} {...field} {...props} />} /> ); }
セレクトボックス
コンポーネント
import React from 'react'; type SelectItem = { label: string; value: string | number; }; type Props = { items: SelectItem[]; errorMassage?: string; } & React.ComponentProps<'select'>; export const SelectBox = React.forwardRef<HTMLSelectElement, Props>(({ items, errorMassage, ...props }, ref) => ( <> <select className="form-select" ref={ref} {...props}> {items.map((item) => ( <option key={item.label} value={item.value}> {item.label} </option> ))} </select> {errorMassage ? <div className="text-danger">{errorMassage}</div> : null} </> ));
import React from 'react'; import { Controller, FieldPath, ControllerRenderProps } from 'react-hook-form'; import { SelectBox } from './SelectBox'; type Props<T> = { fieldName: FieldPath<T>; } & Omit<React.ComponentProps<typeof SelectBox>, 'errorMassage' | keyof ControllerRenderProps>; // 重複するプロパティを除外する export function SelectBoxControl<T>({ fieldName, ...props }: Props<T>) { return ( <Controller name={fieldName} render={({ field, fieldState }) => <SelectBox errorMassage={fieldState.error?.message} {...field} {...props} />} /> ); }
チェックボックス
コンポーネント
import React from 'react'; type Props = { id?: string; label?: string; } & Omit<React.ComponentProps<'input'>, 'id' | 'type'>; export const CheckBox = React.forwardRef<HTMLInputElement, Props>(({ label, id, ...props }, ref) => ( <div className="form-check"> <input className="form-check-input" id={id} ref={ref} type="checkbox" {...props} /> <label className="form-check-label" htmlFor={id}> {label} </label> </div> ));
import React from 'react'; import { Controller, FieldPath, ControllerRenderProps } from 'react-hook-form'; import { CheckBox } from './Checkbox'; type Props<T> = { fieldName: FieldPath<T>; } & Omit<React.ComponentProps<typeof CheckBox>, 'id' | 'checked' | keyof ControllerRenderProps>; // 重複するプロパティを除外する export function CheckBoxControl<T>({ fieldName, ...props }: Props<T>) { return ( <Controller name={fieldName} render={({ field, fieldState }) => { const { value, ...omitField } = field; return <CheckBox checked={value} id={fieldName} {...omitField} {...props} />; }} /> ); }
ラジオボタン
コンポーネント
import React from 'react'; type Props = { id?: string; name: string; label?: string; value: string | number; } & Omit<React.ComponentProps<'input'>, 'id' | 'type'>; export const Radio = React.forwardRef<HTMLInputElement, Props>(({ id, name, label, value, ...props }, ref) => ( <div className="form-check-inline"> <input className="form-check-input" id={id} name={name} ref={ref} type="radio" value={value} {...props} /> <label className="form-check-label" htmlFor={id}> {label} </label> </div> ));
import React from 'react'; import { Controller, FieldPath, ControllerRenderProps } from 'react-hook-form'; import { Radio } from './Radio'; type RadioItem = { label: string; value: string | number; }; type Props<T> = { fieldName: FieldPath<T>; items: RadioItem[]; } & Omit<React.ComponentProps<typeof Radio>, 'id' | 'name' | 'label' | keyof ControllerRenderProps>; // 重複するプロパティを除外する export function RadioGroupControl<T>({ fieldName, items, ...props }: Props<T>) { return ( <Controller name={fieldName} render={({ field, fieldState }) => { const { value, ...omitField } = field; return ( <> {items.map((item) => ( <Radio checked={item.value === value} id={item.value as string} key={item.value} label={item.label} value={item.value} {...omitField} {...props} /> ))} </> ); }} /> ); }
注意
関数コンポーネントは親から子にref
を渡せないため、React.forwardRef
を使用する必要があります。
関数コンポーネント (function components) には ref 属性を使用してはいけません。なぜなら、関数コンポーネントはインスタンスを持たないからです
フォーム実装
上記で実装したスキーマとコンポーネントを利用して、フォーム画面を実装します。
import React from 'react'; import { FormProvider, useForm } from 'react-hook-form'; import { yupResolver } from '@hookform/resolvers/yup'; import { schema, Form } from './validator'; import { CheckBoxControl } from './CheckboxControl'; import { InputControl } from './InputControl'; import { RadioGroupControl } from './RadioGroupControl'; import { SelectBoxControl } from './SelectBoxControl'; export function App() { const methods = useForm<Form>({ mode: 'onBlur', // バリデーションチェックのトリガー(フォーカスを外した時) defaultValues: { // デフォルト値の設定 name: '', email: '', password: '', address: '', gender: 'male', isMailMagazine: false, }, resolver: yupResolver(schema), // スキーマの設定 }); const onSubmit = (data: Form) => console.log(data); // submit時に呼ばれる関数 return ( <div className="container"> <FormProvider {...methods}> <form onSubmit={methods.handleSubmit(onSubmit)}> // submitのイベント設定 <h3 className="pt-5 pb-2">基本情報</h3> <div className="row mb-3"> <span className="col-sm-2 col-form-label">名前</span> <div className="col-sm-10"> <InputControl<Form> fieldName="name" type="text" /> </div> </div> <div className="row mb-3"> <span className="col-sm-2 col-form-label">メールアドレス</span> <div className="col-sm-10"> <InputControl<Form> fieldName="email" type="email" /> </div> </div> <div className="row mb-3"> <span className="col-sm-2 col-form-label">パスワード</span> <div className="col-sm-10"> <InputControl<Form> fieldName="password" type="password" /> </div> </div> <div className="row mb-3"> <span className="col-sm-2 col-form-label">住所</span> <div className="col-sm-10"> <SelectBoxControl<Form> fieldName="address" items={[ { label: '選択してください', value: '', }, { label: '東京', value: 'tokyo', }, { label: '大阪', value: 'osaka', }, ]} /> </div> </div> <div className="row mb-3 align-items-center"> <span className="col-sm-2 col-form-label">性別</span> <div className="col-sm-10"> <RadioGroupControl<Form> fieldName="gender" items={[ { label: '男性', value: 'male', }, { label: '女性', value: 'female', }, { label: 'その他', value: 'other', }, ]} /> </div> </div> <div className="row mb-3 align-items-center"> <span className="col-sm-2 col-form-label">メールマガジン配信</span> <div className="col-sm-10"> <CheckBoxControl<Form> fieldName="isMailMagazine" label="配信あり" /> </div> </div> <button className="btn btn-primary" type="submit"> 登録 </button> </form> </FormProvider> </div> ); }
おわりに
React Hook FormとYupを使用することで簡単にバリデーションチェックできるフォーム画面を実装することができました! これからフォーム画面を実装したい方の参考になれば幸いです。
参考
- React Hook Formを1年以上運用してきたちょっと良く使うためのTips in ログラス(と現状の課題)
- MUI v5 + React Hook Form v7 で、よく使うコンポーネント達を連携してみる