🚦

React Hook Form と Yup でとりあえずのフォーム

8 min read

書くこと

  • React Hook Form と Yup を利用したフォームの基本的な実装例
  • 可変長のフォームの実装例

書かないこと

  • React Hook Form ネイティブのバリデーション含め、他の実装との比較
  • その他ほとんど

以下のライブラリ様のおかげでフォーム実装で大変楽ができたので、この浮いた時間を使ってこれらの基本的な実装例を共有します。
公式ドキュメントの下位互換の気配がありますが、実装例が誰かの参考になれば。

お世話になるライブラリ

React Hook Form - 執筆時最新 v7.21.0

https://react-hook-form.com/

Yup - 執筆時最新 v0.32.11

https://github.com/jquense/yup

インストール

$ npm i yup react-hook-form @hookform/resolvers

スキーマの定義

まず Yup でフォームで受け取るデータのスキーマを定義します。
今回はフォームバリデーションに利用しますが、Yup 自体はデータのパース・バリデーションのためのスキーマビルダーです。

  1. string(), number() などでプリミティブ型にあたるレベルの型を定義。
import * as yup from 'yup';

const str = yup.string();
const num = yup.number();
  1. required(), max(), matches() などを chain してその他の情報を付加。
const reqNum = yup.number().required();
const min8LowerAlphabet = yup.string().min(8).matches(/\l/);
  1. object() でフォームの形式に。
const loginFormSchema = yup.object().required().shape({
  email: yup.string().email().required(),
  password: yup.string().min(8).required()
});

素で使う場合は以下のような形でシンプルなバリデーションに利用できます。

loginFormSchema.isValid(obj); // Promise<boolean>

useForm にスキーマを読ませる

React Hook Form は通常以下のような形でフォームデータのハンドリング用の関数を提供します。
これ以外にも getValues, setError, setFocus, watch, reset など欲しい機能全部入りなので、ほとんどフレームワークです。

import { useForm } from 'react-hook-form';
~~~
export default function Page() {
  const { register, handleSubmit } = useForm({ resolver: yupResolver(schema) });
  
  return (
    <form onSubmit={handleSubmit(data => {
      console.log(data); // { name: 'foo' };
    })}>
      <input type="text" {...register('name')} />
      <button type="submit">OK</button>
    </form>
  )
}

Yup で定義したスキーマに基づいてこの hook の内部でバリデーションなどを行うには、React Hook Form 公式が提供しているリゾルバが必要で、これを使い以下のようにスキーマを渡します。

import { yupResolver } from '@hookform/resolvers/yup';
~~~
  const { register, handleSubmit } = useForm({ resolver: yupResolver(schema) }); // schemaはyupで定義したObjectSchema 
頻繁に使うので省略したい

スキーマを定義する全てのファイルで yupResolver をインポートするのも分かりづらいので、Yup を利用することが決まっている場合は以下のようなフックを介して useForm を使っています。(yupResolver を噛ませてるだけ & any を排除できていませんが)

export function useFormWithYup<T = any>(schema: ObjectSchema<any, any, any>, defaultValues?: any) {
  return useForm<T>({ resolver: yupResolver(schema), defaultValues });
}

これで、サブミットのタイミングで Yup で定義したスキーマに基づいてバリデーションが行われる (後述) ことに加えて、value の自動的な型変換 (キャスト) が行われます。
例えば <input type="number" />value は普通に取得すると string 型で返ってきますが、リゾルバを通過して渡されるデータでは number 型に変換されています。
かなり扱いやすいです。

また、TypeScript を利用している場合は useForm にジェネリクスパラメータとして型を渡すことで、多大な型の恩恵が得られます。体感、handleSubmit, register, errors など全ての範囲でかなりがっつりサポートされていて、フォーム周辺でランタイムエラーを引いたことはないです。

type TForm = Readonly<{
  email: string;
  password: string;
}>;
~~~
  const form = useForm<TForm>();

Yup で定義したスキーマはバリデーションのタイミングで利用されるだけなので、型はこちらで別に定義する必要があります。

型は勝手に認識して欲しい

Yup のスキーマから上の TForm のようなスキーマ (key とプリミティブ型の部分だけ) を抽出する方法があればなお良いと思うんですが、思いつかなかったので、もしご存知の方がいたら教えてください🙏

ちなみに型定義を Yup にジェネリクスで渡す方法で言うと、手元ではうまくいかず、Issue を見るとぱっと見、実用的ではなかったりしますかね?
https://github.com/jquense/yup/issues/1159#issuecomment-741130182

エラーを表示

Yup を useForm に読ませたので、最後にバリデーションエラーをフォームに表示する必要があります。
useForm が返す formState.errors にエラー内容が置かれており、Yup によるバリデーションエラーもここで参照できます。

const { formState: { errors } } = useForm();

// Password が空白の時
// {
//   password: {
//     message: 'password is a required field',
//     ref: <React.ElementRef>,
//     type: 'required'
//   }
// }

上の場合例えば以下のような要素でエラーを表示できます。

{errors.password?.type === 'required' && (<p>パスワードを入力してください</p>)}

が、もちろんエラー理由ごとに手動でエラー表示をしたくないので、スキーマ定義時に条件ごとにエラーメッセージを設定するのが良いかと思います。
ここで設定されたエラーメッセージは errors に渡るので、そのまま表示したいテキストを入れることができます。

const schema = yup.object().required().shape({
  email: yup.string().email('メールアドレスの形式が正しくありません').required('必須項目です'),
  password: yup.string().min(8, '8文字以上で入力してください').matches(/\l/, { message: '半角英小文字で入力してください' }),
});
<form>
  <input type="email" {...register('email')}/>
  <ErrorMessage>{errors.email?.message}</ErrorMessage>
  <input type="password" {...register('password')}/>
  <ErrorMessage>{errors.password?.message}</ErrorMessage>
</form>

バリデーションはサブミット時 (handleSubmit コール時または手動で trigger 時) に行われるため、ユーザーがフォーム入力中は error は空でエラーメッセージが表示されることはなく、また一度サブミットされた後は onChange で逐次検証されて error も更新されるため、ユーザーがエラーメッセージを見て入力内容を修正すれば、即座にエラーメッセージは消えます。

あとは handleSubmit で受け取ったデータをハンドルすれば完了です。

動的なフォーム

動的なと言っても色々あるかと思いますが、ここでは可変長の配列でオブジェクトの入力を期待するフォームを考えます。
例えば、あるワークスペースにユーザーを招待するフォームで、以下のように、複数のユーザーを受け取り、その数を増減できる UI があるかと思います。

これを実装する際、まず Yup のスキーマは、以下のように配列を含むオブジェクトにするのが扱いやすいかと思います。

const schema = yup.object().required().shape({
  invitations: yup.array().of(
    yup.object().required().shape({
      email: yup.string().email().required(),
      permissionId: yup.string(),
    })
  ),
});

フォームの数が変動するスキーマの場合、React Hook Form の useFieldArray を利用して以下のようにフォームをレンダリングすることができます。

const { control } = useForm();
const { fields } = useFieldArray({ control, name: 'invitations' });

return (
  <form
    onSubmit={handleSubmit((data) => {
      console.log(data);
    })}
  >
    {fields.map((field, i) => (
      <div key={field.id}>
        <TextField type="email" {...register(`invitations.${i}.email`)} />
        <TextField type="number" {...register(`invitations.${i}.permissionId`)} />
      </div>
    ))}
    <button>Submit</button>
  </form>
);

上記のように、register にはフィールド名として、'invitations.n.innerFieldName' を渡す形になります。
文字列なので型的に危ういのではと思いきや、useForm に型を渡していれば、画像のように引数の文字列は型が適用されていて、'invitations.n.fieldName' 以外の形式で渡すと TypeError になります。どうやって実装してるの?

フォーム数の増減には、useFieldArray から返される append, removeを使います。

const { fields, append, remove } = useFieldArray({ control, name: 'invitations' });

const addInvitationForm = () => append({}); // 引数はPartial<{ email: string; permissionId: number; }>型

デフォルトでいくつかのフォームを表示したい場合、useFormdefaultValues としてデフォルト値を渡すか、フォーム表示のタイミングで append を指定数行うなどします。

その他

そのまますぎて全然書かなくてもいいと思いますが、てきとうな実装例。

パスワードの一致

setError でエラーをセットする

export default function Page() {
  const {
    register,
    handleSubmit,
    setError,
    formState: { errors },
  } = useForm<Schema>({ resolver: yupResolver(schema) });

  return (
    <form
      onSubmit={handleSubmit(({ password, passwordConfirm }) => {
        if (password !== passwordConfirm) {
          setError('passwordConfirm', { message: 'パスワードが一致しません' });
	  return;
        }
      })}
    >
      <input type="password" {...register('password')} />
      <input type="password" {...register('passwordConfirm')} />
      <p style={{ color: 'red' }}>{errors.passwordConfirm?.message}</p>
    </form>
  );
}

郵便番号からの住所検索

setValue で値をセットする

export default function Page() {
  const {
    register,
    getValues,
    setValue,
  } = useForm<Schema>({ resolver: yupResolver(schema) });

  return (
    <form>
      <input type="number" style={{ appearance: 'textfield' }} {...register('zip1')} />
      <input type="number" style={{ appearance: 'textfield' }} {...register('zip2')} />
      <button
        type="button"
        onClick={async () => {
          const zip = getValues('zip1') + getValues('zip2');
          const address = await getAddressByZip(zip);
          setValue('address', address);
        }}
      >
        住所検索
      </button>
      <input type="text" {...register('address')} />
    </form>
  );
}

以上です

Discussion

ログインするとコメントできます