NRIネットコム社員が様々な視点で、日々の気づきやナレッジを発信するメディアです

注目のタグ

    React Hook FormとYupでフォームを実装する方法

    本記事は  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が標準で対応しているライブラリであるため、今回採用しています。

    使用するライブラリ

    スキーマ定義

    まずスキーマを定義する前に、フォーム用の型を定義します。

    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を使用する必要があります。

    Ref と DOM – React

    関数コンポーネント (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を使用することで簡単にバリデーションチェックできるフォーム画面を実装することができました! これからフォーム画面を実装したい方の参考になれば幸いです。

    参考

    執筆者法地秀樹

    Webアプリケーションエンジニア