react-hook-formでかんたん動的フォーム作成🤗
はじめに
動的フォーム、自前で実装していますか?何かと気をつける事が多くて複雑になりがち・・・ですよね😕
react-hook-form
のuseFieldArray
を使うと、簡単かつ安全に動的フォームが作れることを学んだので、手順と注意点を紹介していきたいと思います!
(本記事ではuseForm
の説明は省きます🙇🏻♀️)
今回作るフォーム📋
3つの項目が1セットで増減するフォームです。増減ボタンは各行に配置する形にしました。
準備✏️
useFieldArrayに与える要素
control
フォームで扱う値を操作するためのオブジェクトです。useForm
から取得しておきましょう。
FormContext
使用時は省略可能です。
const { control } = useForm<{
items: { category: string; name: string; price: string }[];
notes: { note: string };
checked: boolean;
}>();
-
control
とは
react-hook-form
でフォームの値や状態、バリデーションなどを制御するためのオブジェクトです。
フォームの入力要素(input
、select
、textarea
など)と連携して、フォーム状態の管理ができます。(オブジェクト内のプロパティに直接アクセスは禁止されています。)
name
useForm
に指定した型の中からuseFieldArray
で使いたいプロパティの名前を指定します。
オブジェクトの配列のみ使用できます。また、動的な名前はサポートしないそうです。
useFieldArray({
control, name: 'items'
});
その他
shouldUnregister
(アンマウント後にField Array
の登録を解除するかどうか)やrules
(register
と同じ検証ルールのAPI) などが設定できます。
zod
にてバリデーションスキーマを設定する場合はrules
は不要ですね💡
また、keyName
の設定でデフォルトの'id'
から好きな文字列に変更することができますが、次のメジャーアップデートにて削除予定らしいので、あえて使う必要はなさそうです。
useFieldArrayから取得する要素
const { fields, append, remove } = useFieldArray({
control,
name: 'items'
});
fields
name
で指定したオブジェクトのデフォルト値とid
の配列です。
(object & { id: string }
の配列)
-
field.id
一意の文字列です。ありがたい🙏🏻
append,prepend,insert
フォームの入力の初期値を設定し、新しいフォームを追加するための処理です。
-
append
末尾にフォームを追加します。 -
prepend
先頭にフォームを追加します。 -
insert
特定の位置にフォームを追加します。
初期値以外に追加したい箇所のインデックスが必要です。
remove
フォーム削除の処理です。
インデックスを与えて特定の位置のフォームを削除するか、インデックスなしですべてのフォームを削除することができます。
その他
swap
(フォームの入れ替え)、move
(フォームの移動)、replace
(フィールド配列の値全体を置き換える)など、多様な処理が用意されています。
作っていきます💪🏻
フォームのスキーマを定義(今回はzod
で定義)し、useForm
を準備します。
category
、name
、price
の3つのプロパティから成るオブジェクトの配列を定義しました。
const schema = z.object({
items: z.array(
z.object({ category: z.string(), name: z.string(), price: z.string() })
)
});
type FormData = z.infer<typeof schema>;
const initialValue = { category: '', name: '', price: '' };
const { register, handleSubmit, control } = useForm<FormData>({
resolver: zodResolver(schema),
mode: 'onChange',
defaultValues: { items: [initialValue] }
});
useFieldArray
にcontrol
と動的に扱いたいデータ名(オブジェクトの配列名。今回は'items'
)を指定し、フォームの操作のためのfields
、append
、remove
を取得します。
const { fields, append, remove } = useFieldArray({
control,
name: 'items'
});
fields
をmap
処理で展開して、map
の戻り値に各入力欄と操作ボタン(先頭は"行を追加"ボタン、それ以外の行は"削除"ボタン)を配置します。
<form onSubmit={handleSubmit(onSubmit)}>
{fields.map((field, index) => {
const isFirstField = index === 0;
return (
<div key={field.id}>
<select
defaultValue={field.category}
{...register(`items.${index}.category`)}
>
<option value="food">食品</option>
<option value="drink">飲料</option>
<option value="sundries">雑貨</option>
</select>
<input
defaultValue={field.name}
{...register(`items.${index}.name`)}
/>
<input
defaultValue={field.price}
{...register(`items.${index}.price`)}
/>
<span
onClick={() =>
isFirstField
? append(initialValue)
: remove(index)
}
>
{isFirstField ? '行を追加' : '削除'}
</span>
</div>
);
})}
</form>
スタイル等略しているのもありますが、かなりシンプルで書きやすいです。
("行を追加"ボタンを動的フォーム外のレイアウトにすると、判定処理がなくなってさらに単純化できますね。)
submit結果
入力通りの順序で値が反映されていますね✨
もちろん、フォームを増減操作した後も同じ結果です✨✨
スタイル等込みのコード全体
import { zodResolver } from '@hookform/resolvers/zod';
import { FC, useState } from 'react';
import { useForm, useFieldArray } from 'react-hook-form';
import { z } from 'zod';
const schema = z.object({
items: z.array(
z.object({ category: z.string(), name: z.string(), price: z.string() })
)
});
type FormData = z.infer<typeof schema>;
const initialValue = { category: '', name: '', price: '' };
export const Sample: FC = () => {
const { register, handleSubmit, control } = useForm<FormData>({
resolver: zodResolver(schema),
mode: 'onChange',
defaultValues: {
items: [initialValue]
}
});
const { fields, append, remove } = useFieldArray({
control,
name: 'items'
});
const [isSubmitted, setIsSubmitted] = useState(false);
const onSubmit = (data: FormData) => {
setIsSubmitted(true);
console.log(data.items);
};
return (
<div className="flex flex-col p-10 items-center justify-center gap-5">
<h1 className="font-bold text-lg">商品登録</h1>
<form
onSubmit={handleSubmit(onSubmit)}
className="flex flex-col items-center justify-center gap-8"
>
<div className="flex flex-col items-center justify-center gap-5">
{fields.map((field, index) => {
const isFirstField = index === 0;
return (
<div
className="w-full flex items-center gap-3"
key={field.id}
>
<div className="flex flex-col gap-1">
<p className="text-xs">カテゴリ</p>
<select
defaultValue={field.category}
className="bg-white border border-black px-2.5 py-1.5 w-32"
{...register(
`items.${index}.category`
)}
>
<option value="food">食品</option>
<option value="drink">飲料</option>
<option value="sundries">雑貨</option>
</select>
</div>
<div className="flex flex-col gap-1">
<p className="text-xs">商品名</p>
<input
defaultValue={field.name}
className="w-64 bg-white border border-black px-2.5 py-1.5"
{...register(
`items.${index}.name`
)}
/>
</div>
<div className="flex flex-col gap-1">
<p className="text-xs">値段(円)</p>
<div className="flex gap-5">
<input
defaultValue={field.price}
className="w-32 bg-white border border-black px-2.5 py-1.5"
{...register(
`items.${index}.price`
)}
/>
<span
data-is-first={isFirstField}
className="flex items-center px-3 py-2 text-center text-white cursor-pointer rounded-3xl gap-1 text-xs
bg-rose hover:bg-rose-light data-[is-first=true]:bg-cyan data-[is-first=true]:hover:bg-cyan-light hover:font-bold"
onClick={() =>
isFirstField
? append(initialValue)
: remove(index)
}
>
{isFirstField ? '行を追加' : '削除'}
</span>
</div>
</div>
</div>
);
})}
</div>
<button className="p-1.5 rounded w-36 bg-gray text-white hover:font-bold hover:bg-gray-light">
登録
</button>
{isSubmitted && <p>登録しました</p>}
</form>
</div>
);
};
注意点
初期値設定しておく
上記の様にmap
の展開の中にフォーム操作ボタンがある場合は、初期値設定していないとfields
が空なのでフォームと操作ボタンが表示されません。
useForm
のドキュメントでもdefaultValue
の設定を推奨しています。何はともあれ設定しておきましょう〜💡
さいごに
今回は省略していますが、バリデーションの設定や、送信後の処理、リセット処理などを追加すると、より実用に近づくかと思います。
また、動的フォームに限らずですが、zod
にてカスタムバリデーションのエラーパスを動的に指定したい場合について1つ前の記事に書いているので、よろしければ読んでみてください🙇🏻♀️
zod エラーパスの動的指定
少しでも誰かのお役に立てれば幸いです。
それでは、お読みいただきありがとうございました。
Discussion
ぼくもRHFのControllerを使った場合でフォーカスがあたるように調整して、動的フォーム作成にトライしてみました
コメントありがとうございます!
実践されているのを拝見できて、とても嬉しいです☺️ありがとうございます✨