🌷

react-hook-formでかんたん動的フォーム作成🤗

2023/11/14に公開
2

はじめに

動的フォーム、自前で実装していますか?何かと気をつける事が多くて複雑になりがち・・・ですよね😕
react-hook-formuseFieldArrayを使うと、簡単かつ安全に動的フォームが作れることを学んだので、手順と注意点を紹介していきたいと思います!
(本記事ではuseFormの説明は省きます🙇🏻‍♀️)

今回作るフォーム📋

3つの項目が1セットで増減するフォームです。増減ボタンは各行に配置する形にしました。
Image from Gyazo

準備✏️

useFieldArrayに与える要素

control

フォームで扱う値を操作するためのオブジェクトです。useFormから取得しておきましょう。
FormContext使用時は省略可能です。

    const { control } = useForm<{
        items: { category: string; name: string; price: string }[];
        notes: { note: string };
        checked: boolean;
    }>();
  • controlとは
    react-hook-formでフォームの値や状態、バリデーションなどを制御するためのオブジェクトです。
    フォームの入力要素(inputselecttextareaなど)と連携して、フォーム状態の管理ができます。(オブジェクト内のプロパティに直接アクセスは禁止されています。)

name

useFormに指定した型の中からuseFieldArrayで使いたいプロパティの名前を指定します。
オブジェクトの配列のみ使用できます。また、動的な名前はサポートしないそうです。

    useFieldArray({
        control, name: 'items'
    });

その他

shouldUnregister(アンマウント後にField Arrayの登録を解除するかどうか)やrulesregisterと同じ検証ルールの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を準備します。
categorynamepriceの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] }
    });

useFieldArraycontrolと動的に扱いたいデータ名(オブジェクトの配列名。今回は'items')を指定し、フォームの操作のためのfieldsappendremoveを取得します。

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

fieldsmap処理で展開して、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結果

入力通りの順序で値が反映されていますね✨
もちろん、フォームを増減操作した後も同じ結果です✨✨
Image from Gyazo

スタイル等込みのコード全体
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

nap5nap5

ぼくもRHFのControllerを使った場合でフォーカスがあたるように調整して、動的フォーム作成にトライしてみました

ぬくぬく

コメントありがとうございます!
実践されているのを拝見できて、とても嬉しいです☺️ありがとうございます✨