Reactでフォームを実装する際、ライブラリとして下記が有名です。
- Formik
- React Hook Form
- Redux Form
その中でも今回は、Formik
とReact 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を使って整形していきます。
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関数のスプレッド構文で展開した中身には、onChange
やonBlur
が含まれています。
その中に、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
には、required
やminLength
などがあるため、ここで細かなバリデーションを指定することになります。
RHFの場合、submitボタンを押すまでバリデーションの検証は実行されません。
submit時にバリデーションチェックが実行され、エラーが発生した場合は、errorsにオブジェクトとして取得ができます。
ですので、画面上にバリデーションエラーを表示させたい場合は、errorsオブジェクトの中身を表示してあげれば良いです。
<p className='text-red-500'>{errors?.email && errors.email.message}</p>
実際に空でsubmitを実行するとエラーを表示しているのが解ります。
passwordには20文字以内という制限を設けているので、20文字越してもエラーを表示します。
UIライブラリに組み込む場合
Material UIなどの外部CSSライブラリを使用している場合も問題なく使えます。
ただ、普通に使うのではなく、ラッパーコンポーネントを利用して組み込むことになります。
その時に利用するのが、Controller
となります。
今回下記のようなフォームを作成し、Controllerを使ってバリデーション処理を組みました。
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でやることリストを複数個追加する場合のフォームを作成してみました。
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を利用することで、手軽にフォーム作成ができるようになりますので、選択肢として考えてみてはいかがでしょうか?