8
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Reactでフォームを利用する際のreact-hook-formの選択

Last updated at Posted at 2022-08-31

Reactでフォームを実装する際、ライブラリとして下記が有名です。

  • Formik
  • React Hook Form
  • Redux Form

その中でも今回は、FormikReact Hook Formに着目していこうと思います。

トレンドを把握するため、npmのダウンロード数を見てみたのですが、どっこいどっこいでした・・・

近年の傾向としましては、2022年6月以前はFormikの方が優勢だったようですけど、それ以降はreact-hook-form(以降RHFとします)になっているので、次第に興味関心がRHFに移っているような気もします。

この論争に蹴りをつけたいわけではなく、どちらにもメリットデメリットがあります。

今回はRHFを型(TS)含めてしっかり理解するための備忘録となります。

先に総括

私の独断と偏見で結論としては、Formikではなく、RHFを推したいです。

RHFの公式ドキュメントには比較内容が書いてあります(笑)

Formikがライブラリとしてもこれまで利用され続けていた中、ここ1年ぐらいの間でRHFが頭角を現してきた状態です。(執筆時:2022年9月)

Formik

Formikだと入力するごとにレンダリングが発生するため、随時バリデーションチェックをすることになります。

利用方法については、多くの人が使用しているため、文献も多いので取っ掛かりやすいかと思います。

一方、RHFと比較するとライブラリとしては大きいので、利用するための学習コストが少しかかります。

React Hook Form

RHFの場合、入力時にバリデーションチェックは行われず、submitボタンなどを押したタイミングで入力値のチェックが行われます。

submit後はFormikと同じ挙動でバリデーションチェックを入力するごとに実施していきます。

なぜこのような挙動なのかというのは、まずは下記を参照していただければと思います。

RHFにはmodeがあり、デフォルトではonSubmitとなっています。

説明に記載されていますが、

submit イベントからバリデーションがトリガーされ、 無効な入力は onChange イベントリスナーをアタッチして再度バリデーションを行います。

つまり、最初のバリデーションはsubmitイベントで実行され、その後エラーとなった内容はonChangeで検知してバリデーションチェックをするというものですね。

ここがFormikと異なる部分で、Formikは入力毎でバリデーションが発火するようになっていますので、レンダリングに差が出てしまうということですね。

Formikでももちろんsubmit時にバリデーションチェックをするように変更も可能ですが、それでもFormkiの方がレンダリング数が多い結果となっています。

レンダリング数の差異が一つの要因として、RHFの方がパフォーマンスが高くなっています。

レンダリング回数が少なくなっているもう一つの理由として、RHFではrefを使ってフォームを制御していることも理由です。

(stateと異なり、refの更新でレンダリングは発生しないですので)

Reactでレンダリングが発生する条件やrefの使い方については、説明すると長くなってしまいますので別記事でまとめています。

個人的に利用した感想としては、シンプルで判りやすくて必要な機能もだいたい揃っています。

React Hook Formを実際に使う

では、実際にRHFを使ってみようと思います。

インストール

インストールは、公式ドキュメントの通りです。

npm系

npm install react-hook-form

yarn系

yarn add react-hook-form

サンプルフォーム

RHFの公式にはフォームのサンプルコードも掲載されています。

cssの見た目作成については、Tailwind CSSを使って整形していきます。

image.png

import { NextPage } from 'next';
import React from 'react';
import { useForm, SubmitHandler } from 'react-hook-form';

interface Register {
  email: string;
  password: string;
}

const sample: NextPage = () => {
  const {
    register,
    formState: { errors },
    handleSubmit,
  } = useForm<Register>();

  const onSubmit: SubmitHandler<Register> = (data) => console.log(data);

  console.log(errors);
  return (
    <>
      <div className='min-h-full flex flex-col justify-center py-12 sm:px-6 lg:px-8'>
        <h2 className='text-center text-2xl font-bold text-indigo-600'>会員登録フォーム</h2>
        <div className='mt-8 sm:mx-auto sm:w-full sm:max-w-md'>
          <div className='bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10'>
            <form className='space-y-6' onSubmit={handleSubmit(onSubmit)}>
              <div>
                <label htmlFor='email' className='block text-sm font-medium text-gray-700'>
                  Email address
                </label>
                <div className='mt-1'>
                  <input
                    {...register('email', { required: '入力必須です' })}
                    className='appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm'
                  />
                  <p className='text-red-500'>{errors?.email && errors.email.message}</p>
                </div>
              </div>

              <div>
                <label htmlFor='password' className='block text-sm font-medium text-gray-700'>
                  Password
                </label>
                <div className='mt-1'>
                  <input
                    {...register('password', {
                      required: '入力必須です',
                      maxLength: { value: 20, message: '最大20文字までです' },
                    })}
                    type='password'
                    className='appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm'
                  />
                  <p className='text-red-500'>{errors?.password && errors.password.message}</p>
                </div>
              </div>
              <div>
                <button
                  type='submit'
                  className='w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500'
                >
                  Sign Up
                </button>
              </div>
            </form>
          </div>
        </div>
      </div>
    </>
  );
};

export default sample;

使い方の解説

まず着目して欲しいのは、Hooksの部分です。

  const {
    register,
    formState: { errors },
    handleSubmit,
  } = useForm<Register>();

useForm()を使って利用する関数を定義します。

useForm()の実態は、下記でUseFormReturn型を返します。

export declare function useForm<TFieldValues extends FieldValues = FieldValues, TContext = any>(props?: UseFormProps<TFieldValues, TContext>): UseFormReturn<TFieldValues, TContext>;

useFormのジェネリクスに利用するフォームの型を定義したinterfaceを利用するようにします。

UseFormReturnの実態は下記です。

export declare type UseFormReturn<TFieldValues extends FieldValues = FieldValues, TContext = any> = {
    watch: UseFormWatch<TFieldValues>;
    getValues: UseFormGetValues<TFieldValues>;
    getFieldState: UseFormGetFieldState<TFieldValues>;
    setError: UseFormSetError<TFieldValues>;
    clearErrors: UseFormClearErrors<TFieldValues>;
    setValue: UseFormSetValue<TFieldValues>;
    trigger: UseFormTrigger<TFieldValues>;
    formState: FormState<TFieldValues>;
    resetField: UseFormResetField<TFieldValues>;
    reset: UseFormReset<TFieldValues>;
    handleSubmit: UseFormHandleSubmit<TFieldValues>;
    unregister: UseFormUnregister<TFieldValues>;
    control: Control<TFieldValues, TContext>;
    register: UseFormRegister<TFieldValues>;
    setFocus: UseFormSetFocus<TFieldValues>;
};

上記のことから、watchやregister関数などが使えるようになっています。

また、inputタグ側では下記のように定義しています。

<input {...register('email', { required: '入力必須です' })} />

registerの展開がいまいちピンと来にくいところですが、これは一体何をしているのでしょうか?

公式ドキュメントに記載されているのですが、

<input 
  onChange={onChange} // assign onChange event 
  onBlur={onBlur} // assign onBlur event
  name={name} // assign name prop
  ref={ref} // assign ref prop
/>
// same as above
<input {...register('firstName')} />

register関数のスプレッド構文で展開した中身には、onChangeonBlurが含まれています。

その中に、refがあるため、RHFではrefで制御していることとなります。

register関数は、UseFormRegister<TFieldValues>の型です。

UseFormRegisterは、下記のようになっているため第二引数にオプションとして、RegisterOptions<TFieldValues, TFieldName>を渡しています。

export declare type UseFormRegister<TFieldValues extends FieldValues> = <TFieldName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>>(name: TFieldName, options?: RegisterOptions<TFieldValues, TFieldName>) => UseFormRegisterReturn<TFieldName>;
export declare type RegisterOptions<TFieldValues extends FieldValues = FieldValues, TFieldName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>> = Partial<{
    required: Message | ValidationRule<boolean>;
    min: ValidationRule<number | string>;
    max: ValidationRule<number | string>;
    maxLength: ValidationRule<number>;
    minLength: ValidationRule<number>;
    pattern: ValidationRule<RegExp>;
    validate: Validate<FieldPathValue<TFieldValues, TFieldName>> | Record<string, Validate<FieldPathValue<TFieldValues, TFieldName>>>;
    valueAsNumber: boolean;
    valueAsDate: boolean;
    value: FieldPathValue<TFieldValues, TFieldName>;
    setValueAs: (value: any) => any;
    shouldUnregister?: boolean;
    onChange?: (event: any) => void;
    onBlur?: (event: any) => void;
    disabled: boolean;
    deps: InternalFieldName | InternalFieldName[];
}>;

RegisterOptionsには、requiredminLengthなどがあるため、ここで細かなバリデーションを指定することになります。

RHFの場合、submitボタンを押すまでバリデーションの検証は実行されません。

submit時にバリデーションチェックが実行され、エラーが発生した場合は、errorsにオブジェクトとして取得ができます。

ですので、画面上にバリデーションエラーを表示させたい場合は、errorsオブジェクトの中身を表示してあげれば良いです。

<p className='text-red-500'>{errors?.email && errors.email.message}</p>

実際に空でsubmitを実行するとエラーを表示しているのが解ります。

image.png

passwordには20文字以内という制限を設けているので、20文字越してもエラーを表示します。

image.png

UIライブラリに組み込む場合

Material UIなどの外部CSSライブラリを使用している場合も問題なく使えます。

ただ、普通に使うのではなく、ラッパーコンポーネントを利用して組み込むことになります。

その時に利用するのが、Controllerとなります。

今回下記のようなフォームを作成し、Controllerを使ってバリデーション処理を組みました。

image.png

import { Box, Button, Container, TextField, Typography } from '@mui/material';
import { NextPage } from 'next';
import React from 'react';
import { useForm, SubmitHandler, Controller } from 'react-hook-form';

interface Register {
  email: string;
  password: string;
}

const login: NextPage = () => {
  const {
    control,
    register,
    formState: { errors },
    handleSubmit,
  } = useForm<Register>();

  const onSubmit: SubmitHandler<Register> = (data) => console.log(data);

  console.log(errors);
  return (
    <>
      <Container maxWidth='sm' sx={{ marginTop: 10 }}>
        <Typography variant='h5' align='center' mb={2}>
          ログインフォーム
        </Typography>
        <form onSubmit={handleSubmit(onSubmit)}>
          <Controller
            name='email'
            control={control}
            rules={{ required: '必須項目です' }}
            render={({ field }) => (
              <TextField
                {...field}
                id='email'
                label='Email'
                variant='outlined'
                fullWidth
                margin='normal'
                error={errors.email ? true : false}
                helperText={errors.email && errors.email.message}
              />
            )}
          />
          <Controller
            name='password'
            control={control}
            rules={{ required: '入力必須です', maxLength: { value: 20, message: '最大20文字までです' } }}
            render={({ field }) => (
              // render={({ field: { onChange, onBlur, value, name, ref } }) => (
              <TextField
                {...field}
                id='password'
                label='Password'
                type='password'
                autoComplete='current-password'
                fullWidth
                margin='normal'
                error={errors.password ? true : false}
                helperText={errors.password && errors.password.message}
              />
            )}
          />
          <Button variant='contained' fullWidth sx={{ marginTop: 5 }} type='submit'>
            Login
          </Button>
        </form>
      </Container>
    </>
  );
};

export default login;

Conrtollerにはrender属性を持ち、renderに制御するMaterial UIのコンポーネントを渡してあげることで、RHFで制御することが可能となります。

ここで注意しなければならないのが、renderには先ほどのようにregisterを渡すことができません。

<input {...field} {...register('test')} />;

fieldの実態は、ControllerRenderProps<TFieldValues, TName>です。

ControllerRenderPropsは下記になっています。

export declare type ControllerRenderProps<TFieldValues extends FieldValues = FieldValues, TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>> = {
    onChange: (...event: any[]) => void;
    onBlur: Noop;
    value: FieldPathValue<TFieldValues, TName>;
    name: TName;
    ref: RefCallBack;
};

つまりrender関数の引数であるfieldには、

  • onChange
  • onBlur
  • value
  • name
  • ref

がオブジェクトとして含まれていますので、fieldをコンポーネントに渡してあげるようにします。

<input {...field} />;

スキーマバリデーションを利用する場合

RHFで、バリデーションの実装は可能ですが、よりカスタマイズ性や再利用性を考えると、Yupなどのスキーマベースのバリデーションも使いたいところで、RHFはサポートしています。

まずはYupのインストールが必要です。

npm系

npm install @hookform/resolvers yup

yarn系

yarn add @hookform/resolvers yup

yupResolver関数にYupのスキーマを渡してあげることで使用可能となります。

import React from "react";
import { useForm } from "react-hook-form";
import { yupResolver } from '@hookform/resolvers/yup';
import * as yup from "yup";

const schema = yup.object().shape({
  firstName: yup.string().required(),
  age: yup.number().positive().integer().required(),
}).required();

const { register, handleSubmit, errors } = useForm({
  resolver: yupResolver(schema)
});

動的なフォーム追加

RHFでは動的にフォームを増やしたりすることもuseFieldArrayというHooksを利用することで容易にできます。

Tailwindでやることリストを複数個追加する場合のフォームを作成してみました。

image.png

appendを利用することでフォームを追加し、removeを利用することでフォームを削除することが可能となります。

このようにさまざまなフォームの形態に柔軟に対応することが可能となります。

useFieldArrayにはその他にも、

  • swap
  • move
  • prepend

などの関数を持ち合わせています。

export declare type UseFieldArrayReturn<TFieldValues extends FieldValues = FieldValues, TFieldArrayName extends FieldArrayPath<TFieldValues> = FieldArrayPath<TFieldValues>, TKeyName extends string = 'id'> = {
    swap: UseFieldArraySwap;
    move: UseFieldArrayMove;
    prepend: UseFieldArrayPrepend<TFieldValues, TFieldArrayName>;
    append: UseFieldArrayAppend<TFieldValues, TFieldArrayName>;
    remove: UseFieldArrayRemove;
    insert: UseFieldArrayInsert<TFieldValues, TFieldArrayName>;
    update: UseFieldArrayUpdate<TFieldValues, TFieldArrayName>;
    replace: UseFieldArrayReplace<TFieldValues, TFieldArrayName>;
    fields: FieldArrayWithId<TFieldValues, TFieldArrayName, TKeyName>[];
};

まとめ

いかがだったでしょうか。

UI/UXを高めるためには、サーバーにリクエストを送る前にデータに問題がないかをチェックした上でリクエストできるようなフォームが理想です。

React Hook Formを利用することで、手軽にフォーム作成ができるようになりますので、選択肢として考えてみてはいかがでしょうか?

8
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?