React Hook Form + yup によるフォーム実装あれこれ

はじめに

vivit株式会社でフロントエンドエンジニアをしている氏家です。 私は現在、アウトドア用品の中古品買取と販売を行うhinataリユースの内製システムの開発に携わっています。

www.hinatareuse.jp

システムは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;

↓ブラウザ画面 f:id:rugk:20211006180519p:plain

このままでは送信ボタンを押しても何も起きないので、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;

↓フォームに不備があるとエラーメッセージが表示される f:id:rugk:20211006201421p:plain

ネストしたフォームの設定

フォーム部分をコンポーネントとして切り出したいときがあると思いますが、フォームがネストしていると都度propsをコンポーネントに渡す必要があり面倒だと思うかもしれません。

しかしReact Hook FormではuseFormContext()を使用することで簡単に実装することができます。

react-hook-form.com

- 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>
        好きな偶数:&nbsp;
        {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,
});

コンポーネントがどこで呼び出されるのか気にする必要がないため、フォームを再利用できるというメリットもあります。

↓ブラウザ画面 f:id:rugk:20211008155709p:plain

UIコンポーネントライブラリを使用する

React Hook FormはAnt DesignやMaterial UIなどの制御されたコンポーネントに対しても設定することができます。

ドキュメントでは2つのやり方が紹介されていますが、今回はAnt DesignのコンポーネントControllerを使用するやり方で実装していきます。

react-hook-form.com

- 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 に渡すことで設定できます)

↓ブラウザ画面 f:id:rugk:20211011021243p:plain

手こずった点

紹介するのはどちらもyupによるバリデーションの設定についてです。

ドキュメントを読んでも分かりづらい内容だったので共有させていただきます。

バリデーションエラーのメッセージ変更

エラーメッセージの変更には以下の2つの方法があります。

  1. yup.setLocale()で各バリデーションごとのメッセージを定義する
  2. バリデーション関数の引数に渡す

1つ目はyupのデフォルトメッセージを変更する方法です。

github.com

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()メソッドを使用することで実装することができます。

github.com

yup.number().test("6以外は入力しないでください", (value) => {
  return value === 6;
})

新たなバリデーション関数をaddMethod()で定義することもできます。

github.com

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サービスをともに成長させる仲間を募集しています。

下記からご確認ください。

www.wantedly.com