仕事でReact Hook Formのv6を使用しているのですが、そろそろ執筆時点で最新のv7に更新したいなと思っておりReact Hook Formについて学習し直したのであれこれ思うことについて書いていきます。サンプルコードとかもそのうち載せていい記事にしたいなと思うんですが、一旦はこの数日間考えてみたことを整理して記録するために記事にします。
書いてるうちに長くなってきたので先に個人的にうまくReact Hook Formと付き合うための結論を書いときます。
- ボタン操作だけで済む画面なら
useState
で管理する。 - ボタンだけでなく、キーボードやフリック入力をしないといけない入力用のコンポーネントならボタンの状態も含めて全てReact Hook Formで管理する。
- 複数画面にまたがるフォームの場合はページごとに確認ボタン(次へボタンかもしれない)を設置して、確認ボタンが押された時にフォームの入力内容をReduxに送信する。全てのページの入力が済んだら全ての入力内容をまとめてAPIで送信する。
- 戻るボタンが押された場合はReduxから前の画面のフォームで入力した内容を取得して、デフォルト値として
useForm({defaultValues:<Reduxから取得したデフォルト値>})
のようにセットすると再度一から入力する必要がなくなる。
- 戻るボタンが押された場合はReduxから前の画面のフォームで入力した内容を取得して、デフォルト値として
の基準でReact Hook FormとuseState、Reduxの使い分けをするのがわかりやすくて不便な場面も少なくなりそうな方針かなと感じています。
なぜReact Hook Formを使うのか
そもそも何故React Hook Formを使うのかということなんですが、ここが一番理解しておかないといけない点だと思うんですが、レンダーの回数を減らせるからです。
useState
とかでテキストの入力を制御すると入力の状態が更新されるたびにレンダーされるので描画するのに時間のかかるページだと割と遅くなります。(正直、PCとかスマホ買い換えてからそこまで実感したことはありませんが)
なので、このメリットを活かせる場面では積極的にReact Hook Formを使用すると良いと思います。
React Hook Form V7の良いところ
V6と比べてTypeScriptとの親和性がめちゃくちゃ高いです。
ホームページからそのままコードを拝借してきます。
import React from "react";
import { useForm, SubmitHandler } from "react-hook-form";
type Inputs = {
example: string,
exampleRequired: string,
};
export default function App() {
const { register, handleSubmit, watch, formState: { errors } } = useForm<Inputs>();
const onSubmit: SubmitHandler<Inputs> = data => console.log(data);
console.log(watch("example")) // watch input value by passing the name of it
return (
/* "handleSubmit" will validate your inputs before invoking "onSubmit" */
<form onSubmit={handleSubmit(onSubmit)}>
{/* register your input into the hook by invoking the "register" function */}
<input defaultValue="test" {...register("example")} />
{/* include validation with required or other standard HTML validation rules */}
<input {...register("exampleRequired", { required: true })} />
{/* errors will return when field validation fails */}
{errors.exampleRequired && <span>This field is required</span>}
<input type="submit" />
</form>
);
}
useForm<Inputs>
のように宣言しているのでregister
の引数にはInput
のキーしか渡せません。
さらにInputs
をネストさせてみます。
type Inputs = {
example: string,
exampleRequired: string,
exampleNested: {
hello:string,
},
};
のようになっていた場合、
register('exampleNested.hello')
のように引数に.
を使用してキーを繋げることができます。それも何と型チェックをしてくれるのでregister('exampleNested.hallo')
のようにスペルミスをしていた場合でもコンパイル時にはエラーを発見してくれるので安心です。
また、今回は省略しますが、useFieldArray
を使用した際もTypeScriptの恩恵がかなり受けられて便利です。
難しいところ
TypeScriptのサポートも充実して使いやすくなったとは思うんですが、useState
やReduxなどによる通常の状態管理と併用するときにどこまでをReact Hook Formに任せれば良いのか判断するのが難しいと思っています。
例えば、ボタンをクリックするとパネルが開いてパネルの中には入力フォームがあるというような画面を考えてください。
サンプルコードを置いておきます。
import React, { useState } from 'react';
import { SubmitHandler, useForm, UseFormReturn } from 'react-hook-form';
interface FormInput{
firstName: string,
lastName: string,
phoneNumber:number,
}
export default function App() {
const [isPanelOpen, setIsPanelOpen] = useState(false);
const formMethods = useForm<FormInput>();
const { register, handleSubmit } = formMethods;
const onSubmit: SubmitHandler<FormInput> = (input) => {
console.log(input);
};
return (
<div>
<form onSubmit={handleSubmit(onSubmit)}>
<input type="text" {...register('firstName')} />
<input type="text" {...register('lastName')} />
<button
type="button"
onClick={() => { setIsPanelOpen((prev) => !prev); }}
>
追加で入力する。
</button>
{isPanelOpen && <AdditionalForm formMethods={formMethods} />}
<input type="submit" />
</form>
</div>
);
}
interface AdditionalFormProps{
formMethods:UseFormReturn<FormInput>
}
function AdditionalForm({ formMethods }: AdditionalFormProps) {
const { register } = formMethods;
return (
<div>
<input type="number" {...register('phoneNumber')} />
</div>
);
}
上のコンポーネントでは「追加で入力する。」ボタンを押すと電話番号を入力する入力欄が表示されますが、再度「追加で入力する。」ボタンを押すとその入力欄は消えてしまいます。
しかし、入力欄が消えても以前に入力した内容は消えていないので送信ボタンが押されてしまうと電話番号のデータも送られてしまいます。(電話番号の入力欄が表示されていない場合はundefined
にしたいかもしれないのに)
このように通常の状態管理とReact Hook Formが絡むと面倒になる印象です。
簡単に考えられる場合は以下のように入力のデフォルト値をAPIで非同期的に取得する場合です。
const [defaultValues,setDefaultValues] = useState<FormInput>();
const fetchDefaultValues = async ()=>{
const fetchedValues = await fetchData();
setDefaultValues(fetchedValues);
}
useEffect(()=>{
fetchDefaultValues();
},[])
そのような場合は以下のようにuseForm()
関数の返り値にreset
関数があるのでデフォルト値を設定することができます。
export default function App() {
const formMethods = useForm<FormInput>();
const {
register, handleSubmit,
reset,
} = formMethods;
// 5秒後にデフォルト値をセットする。
const [defaultValues, setDefaultValues] = useState<FormInput>();
useEffect(() => {
window.setTimeout(() => {
setDefaultValues({
firstName: 'michael',
lastName: 'nakazato',
});
}, 5000);
}, []);
// デフォルト値が更新されたらフォームに反映する。
useEffect(() => {
reset(defaultValues);
}, [defaultValues]);
const onSubmit: SubmitHandler<FormInput> = (input) => {
console.log(input);
};
return (
<div>
<form onSubmit={handleSubmit(onSubmit)}>
<input type="text" {...register('firstName')} />
<input type="text" {...register('lastName')} />
<input type="submit" />
</form>
</div>
);
}
以下の箇所でデフォルト値がセットされたのを検知してフォームに反映させています。
useEffect(() => {
reset(defaultValues);
}, [defaultValues]);
デフォルト値の場合でもreset
関数を呼び出さないといけないのでひと手間掛かっていますが、先ほどのように通常の状態管理と絡む場合は常にReact Hook Formが管理していない状態についても考慮に入れないといけないのが難しいです。
例えば、以下のように送信時に実行される関数の中でuseState
で管理している状態の値も気にするとかしないといけない場面が多くなると思います。
const onSubmit: SubmitHandler<FormInput> = (input) => {
if(!isPanelOpen){
console.log({
firstName:input.firstName,
lastName:input.lastName,
})
return;
}
console.log(input);
};
なので、入力に関係するものは全てReact Hook Formで管理するというのも一つの手だと思います。
それが以下のコードになります。このコード内ではパネルの開け閉めもReactHookFormで管理しています。
import React, { useEffect, useState } from 'react';
import { SubmitHandler, useForm, UseFormReturn } from 'react-hook-form';
import { setTimeout } from 'timers/promises';
interface FormInput{
firstName: string,
lastName: string,
phoneNumber: string,
isPanelOpen: boolean,
}
export default function App() {
const formMethods = useForm<FormInput>({
defaultValues: {
isPanelOpen: false,
},
});
const {
register,
handleSubmit,
reset,
watch,
setValue,
} = formMethods;
// 3秒後にデフォルト値を更新する。
useEffect(() => {
window.setTimeout(() => {
reset({
firstName: 'michael',
lastName: 'nakazato',
phoneNumber: '0120-111-111',
isPanelOpen: false,
});
}, 3000);
}, []);
// React Hook Formが管理しているisPanel属性を監視する。
const isPanelOpen = watch('isPanelOpen');
const onSubmit: SubmitHandler<FormInput> = (input) => {
if (input.isPanelOpen) {
console.log({
firstName: input.firstName,
lastName: input.lastName,
phoneNumber: input.phoneNumber,
});
} else {
console.log({
firstName: input.firstName,
lastName: input.lastName,
});
}
};
return (
<div>
<form onSubmit={handleSubmit(onSubmit)}>
<input type="text" {...register('firstName')} />
<input type="text" {...register('lastName')} />
<button
type="button"
// isPanelOpenをトグルする。
onClick={() => { setValue('isPanelOpen', !isPanelOpen); }}
>
追加で入力する。
</button>
{isPanelOpen && <AdditionalForm formMethods={formMethods} />}
<input type="submit" />
</form>
</div>
);
}
interface AdditionalFormProps{
formMethods:UseFormReturn<FormInput>
}
function AdditionalForm({ formMethods }: AdditionalFormProps) {
const { register } = formMethods;
return (
<div>
<input type="text" {...register('phoneNumber')} />
</div>
);
}
他にも入力欄をリストとして持っている場合には以下のようなコードを書いてしまうかもしれません。
import React, { useEffect, useState } from 'react';
import { SubmitHandler, useForm, UseFormReturn } from 'react-hook-form';
import { setTimeout } from 'timers/promises';
interface FormInput{
firstName: string,
lastName: string,
friends: {
firstName: string,
lastName: string,
}[]
}
export default function App() {
const formMethods = useForm<FormInput>();
const {
register,
handleSubmit,
reset,
watch,
setValue,
} = formMethods;
const onSubmit: SubmitHandler<FormInput> = (input) => {
console.log({
firstName: input.firstName,
lastName: input.lastName,
});
};
const [friends, setFriends] = useState<{
firstName: string,
lastName: string,
}[]>([]);
return (
<div>
<form onSubmit={handleSubmit(onSubmit)}>
<input type="text" {...register('firstName')} />
<input type="text" {...register('lastName')} />
<h2>友達</h2>
{
friends.map((frined, friendIndex) => (
<FriendForm
// eslint-disable-next-line react/no-array-index-key
key={friendIndex}
formMethods={formMethods}
friendIndex={friendIndex}
/>
))
}
<button
type="button"
onClick={() => {
setFriends((prev) => [...prev, {
firstName: '',
lastName: '',
}]);
}}
>
友達を追加
</button>
<input type="submit" />
</form>
</div>
);
}
interface FriendFormProps{
friendIndex:number,
formMethods:UseFormReturn<FormInput>
}
function FriendForm(props: FriendFormProps) {
const { formMethods, friendIndex } = props;
return (
<>
<input type="text" {...formMethods.register(`friends.${friendIndex}.firstName`)} />
<input type="text" {...formMethods.register(`friends.${friendIndex}.lastName`)} />
</>
);
}
上のコードではuseState
で友達についての情報をfriends
として管理しているのにも関わらず、FriendForm
コンポーネントではfriends
の値に関係なく、formMethods
から直接入力を管理しています。
つまり、結局friends
の情報は有効に管理できていないことになります。
なので、実際には以下のようにuseFieldArray
を使うと良いでしょう。
import React, { useEffect, useState } from 'react';
import {
SubmitHandler, useFieldArray, useForm, UseFormReturn,
} from 'react-hook-form';
interface FormInput{
firstName: string,
lastName: string,
friends: {
firstName: string,
lastName: string,
}[]
}
export default function App() {
const formMethods = useForm<FormInput>();
const {
register,
handleSubmit,
control,
} = formMethods;
const onSubmit: SubmitHandler<FormInput> = (input) => {
console.log({ input });
};
const { fields: friends, append: appendFriend } = useFieldArray({
control,
name: 'friends',
});
return (
<div>
<form onSubmit={handleSubmit(onSubmit)}>
<input type="text" {...register('firstName')} />
<input type="text" {...register('lastName')} />
<h2>友達</h2>
{
friends.map((frined, friendIndex) => (
<FriendForm
// eslint-disable-next-line react/no-array-index-key
key={friendIndex}
formMethods={formMethods}
friendIndex={friendIndex}
/>
))
}
<button
type="button"
onClick={() => {
appendFriend({
firstName: '',
lastName: '',
});
}}
>
友達を追加
</button>
<input type="submit" />
</form>
</div>
);
}
interface FriendFormProps{
friendIndex:number,
formMethods:UseFormReturn<FormInput>
}
function FriendForm(props: FriendFormProps) {
const { formMethods, friendIndex } = props;
return (
<>
<input type="text" {...formMethods.register(`friends.${friendIndex}.firstName`)} />
<input type="text" {...formMethods.register(`friends.${friendIndex}.lastName`)} />
</>
);
}
また、複数の入力ページに跨ってフォームを入力して欲しい場面などは多々あると思いますが、その場合は確認ボタンが押されるなどそのページでの入力内容が確定したタイミングでReact Hook Formが管理している状態をReduxなどに保存してからページの移動を行うことで複数画面に渡るフォームを作成することができます。
結論
個人的にですが
- ボタン操作だけで済む画面なら
useState
で管理する。 - ボタンだけでなく、キーボードやフリック入力をしないといけない入力用のコンポーネントならボタンの状態も含めて全てReact Hook Formで管理する。
- 複数画面にまたがるフォームの場合はページごとに確認ボタン(次へボタンかもしれない)を設置して、確認ボタンが押された時にフォームの入力内容をReduxに送信する。全てのページの入力が済んだら全ての入力内容をまとめてAPIで送信する。
- 戻るボタンが押された場合はReduxから前の画面のフォームで入力した内容を取得して、デフォルト値として
useForm({defaultValues:<Reduxから取得したデフォルト値>})
のようにセットすると再度一から入力する必要がなくなる。
- 戻るボタンが押された場合はReduxから前の画面のフォームで入力した内容を取得して、デフォルト値として
の基準でReact Hook FormとuseState、Reduxの使い分けをするのがわかりやすくて不便な場面も少なくなりそうな方針かなと感じています。