🐤

react-hook-formハマりポイント③ 🖍️zodでpath動的指定

2023/11/06に公開

はじめに

以前にもreact-hook-formzodの組み合わせの記事を書いたのですが、今回もその組み合わせでの話をしようと思います。

それぞれのライブラリの説明は割愛いたします🙇🏻‍♀️

こちらで軽く紹介しています

ハマった点

まず、実装しようとしていたのは、
入力欄が複数あるフォームに対して(動的フォームも含む)、複数の欄の入力値の重複チェックを行い、重複した欄のみエラーを表示させたい
というものでした。

▼イメージ
Image from Gyazo

コードはこんな感じ
  • zodスキーマにて対象フォームを配列で定義
  • mapで任意の個数のフォームを定義
    useFieldArrayをつかった動的フォームも同様

※スタイルは見易くする程度にあてています(tailwindです)

import { zodResolver } from '@hookform/resolvers/zod';
import { FC, useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';

const schema = z.object({
    favoriteColors: z.array(z.string())
});

type FormData = z.infer<typeof schema>;

export const Sample: FC = () => {
    const {
        register,
        handleSubmit,
        formState: { errors }
    } = useForm<FormData>({
        resolver: zodResolver(schema),
        mode: 'onChange'
    });

    const [isSubmitted, setIsSubmitted] = useState(false);
    const formLength = 5;

    const onSubmit = () => setIsSubmitted(true);

    return (
        <div className="flex flex-col p-10 items-center justify-center gap-8 w-1/2">
            <h1 className="font-bold text-lg">好きな色を教えてください</h1>
            <form
                onSubmit={handleSubmit(onSubmit)}
                className="flex flex-col items-center justify-center gap-5"
            >
                {Array.from(Array(formLength).keys()).map((index) => {
                    const error = errors.favoriteColors?.[index]?.message;
                    return (
                        <div>
                            <input
                                className="bg-white border border-black px-2.5 py-1.5"
                                {...register(`favoriteColors.${index}`)}
                            />
                            {error != null && (
                                <p className="text-xs text-rose font-bold pt-2">
                                    {error}
                                </p>
                            )}
                        </div>
                    );
                })}
                <button className="p-1.5 rounded w-36 bg-rose text-white hover:font-bold hover:bg-rose-light">
                    送信
                </button>
                {isSubmitted && <p>送信しました</p>}
            </form>
        </div>
    );
};

しかし、

  • zodrefineはカスタム検証ロジックを実装できるが、pathが固定である
  • onChangeタイミングでuseFormsetError発火するエラー判定処理を別途作成しても、zodスキーマにエラー判定を入れないとフォーム送信時にhandleSubmitでエラーが消えてしまう
    handleSubmit後に同じエラー判定を組み込むのも猥雑だしなぁ・・・)

という感じでエラー対象のpathを動的に指定するのに詰まっておりました。

解決方法

単純な話ですが、zodsuperRefineを使うと実現できるのです。
(なんとなくで敬遠していたのを大いに反省しました🤯)

https://zod.dev/?id=superrefine

refine.refine(validator: (data:T)=>any, params?: RefineParams)の形で、バリデーション判定をboolで返し、pathの指定は引数(RefineParams)にしなければなりませんでした。

実はrefinesuperRefineの糖衣構文であり、superRefineの方を使用すると処理中にctx.addIssuepathmessageを指定してissueをいくつでも追加することができます✨✨
(バリデーションがパスしたかどうかは、boolを返すのではなく、ctx.addIssue()を使用する必要があります。ctx.addIssueが呼び出されなければバリデーションはパスします。)

superRefineを使ってみる

superRefine内で、まず、getDuplicateIndexes()で重複したフォームのインデックスを取得し、そしてaddIssuepath: ['favoriteColors', index]と動的に対象のインデックスを指定します。
これで簡単に実現することができました!

const schema = z
    .object({
        favoriteColors: z.array(z.string())
    })
    .superRefine((value, ctx) => {
        getDuplicateIndexes({ array: value.favoriteColors }).forEach(
            (index) => {
                ctx.addIssue({
                    code: z.ZodIssueCode.custom,
                    message: '重複しています',
                    path: ['favoriteColors', index]
                });
            }
        );
    });

Image from Gyazo

めっちゃ便利・・・!

コード全体
import { zodResolver } from '@hookform/resolvers/zod';
import { FC, useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';

export const getDuplicateIndexes = ({ array }: { array: string[] }) => {
    const indexMap = new Map();
    const duplicateIndexes = [];

    for (let i = 0; i < array.length; i++) {
        const propValue = array[i];

        if (indexMap.has(propValue) && propValue !== '') {
            duplicateIndexes.push(indexMap.get(propValue), i);
        } else {
            indexMap.set(propValue, i);
        }
    }
    // 重複対象のインデックスの重複を削除
    const filteredDuplicateIndexes = duplicateIndexes.filter(
        (value, index, self) => self.indexOf(value) === index
    );
    return filteredDuplicateIndexes;
};

const schema = z
    .object({
        favoriteColors: z.array(z.string())
    })
    .superRefine((value, ctx) => {
        getDuplicateIndexes({ array: value.favoriteColors }).forEach(
            (index) => {
                ctx.addIssue({
                    code: z.ZodIssueCode.custom,
                    message: '重複しています',
                    path: ['favoriteColors', index]
                });
            }
        );
    });

type FormData = z.infer<typeof schema>;

export const Sample: FC = () => {
    const {
        register,
        handleSubmit,
        trigger,
        formState: { errors }
    } = useForm<FormData>({
        resolver: zodResolver(schema),
        mode: 'onChange'
    });

    const [isSubmitted, setIsSubmitted] = useState(false);
    const formLength = 5;

    const onSubmit = () => setIsSubmitted(true);

    return (
        <div className="flex flex-col p-10 items-center justify-center gap-8 w-1/2">
            <h1 className="font-bold text-lg">好きな色を教えてください</h1>
            <form
                onSubmit={handleSubmit(onSubmit)}
                className="flex flex-col items-center justify-center gap-5"
            >
                {Array.from(Array(formLength).keys()).map((index) => {
                    const error = errors.favoriteColors?.[index]?.message;
                    return (
                        <div>
                            <input
                                className="bg-white border border-black px-2.5 py-1.5"
                                {...register(`favoriteColors.${index}`, {
                                    onChange: () => {
                                        trigger('favoriteColors');
                                    }
                                })}
                            />
                            {error != null && (
                                <p className="text-xs text-rose font-bold pt-2">
                                    {error}
                                </p>
                            )}
                        </div>
                    );
                })}
                <button className="p-1.5 rounded w-36 bg-rose text-white hover:font-bold hover:bg-rose-light">
                    送信
                </button>
                {isSubmitted && <p>送信しました</p>}
            </form>
        </div>
    );
};

ちなみに、onChangeのタイミングで重複しているフォームすべてにエラー文を表示させたいため、 trigger('favoriteColors')を入れています。triggerの方はindexの指定なしでも、path: ['favoriteColors', index]が効いてくれるようですね。
ちなみに、エラー発火はregistertrigger、そしてaddIssuepathに基づいているようですが、superRefine自体の実行はuseFormtype : 'onChange'だと、onChangeタイミングで毎回走るようです。

また、superRefineは返り値が検証ロジックではないため、動的なpath指定以外にも、同じ前提条件で複数パターンの検証したいときなども便利です。

さいごに

superRefineを知るきっかけとなり、学びになるいい機会でした。
改善点やもっと便利な使い方など、なにかあれば教えていただけると幸いです。

それでは読んでいただきありがとうございました。

エックスポイントワン技術ブログ

Discussion