NRIネットコム Blog

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アプリケーションエンジニア