react-hook-formハマりポイント③ 🖍️zodでpath動的指定
はじめに
以前にもreact-hook-form
とzod
の組み合わせの記事を書いたのですが、今回もその組み合わせでの話をしようと思います。
それぞれのライブラリの説明は割愛いたします🙇🏻♀️
こちらで軽く紹介しています
ハマった点
まず、実装しようとしていたのは、
入力欄が複数あるフォームに対して(動的フォームも含む)、複数の欄の入力値の重複チェックを行い、重複した欄のみエラーを表示させたい
というものでした。
コードはこんな感じ
- 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>
);
};
しかし、
-
zod
のrefine
はカスタム検証ロジックを実装できるが、path
が固定である -
onChange
タイミングでuseForm
のsetError
発火するエラー判定処理を別途作成しても、zodスキーマにエラー判定を入れないとフォーム送信時にhandleSubmit
でエラーが消えてしまう
(handleSubmit
後に同じエラー判定を組み込むのも猥雑だしなぁ・・・)
という感じでエラー対象のpath
を動的に指定するのに詰まっておりました。
解決方法
単純な話ですが、zod
のsuperRefine
を使うと実現できるのです。
(なんとなくで敬遠していたのを大いに反省しました🤯)
refine
は.refine(validator: (data:T)=>any, params?: RefineParams)
の形で、バリデーション判定をbool
で返し、path
の指定は引数(RefineParams
)にしなければなりませんでした。
実はrefine
はsuperRefine
の糖衣構文であり、superRefine
の方を使用すると処理中にctx.addIssue
でpath
やmessage
を指定してissueをいくつでも追加することができます✨✨
(バリデーションがパスしたかどうかは、bool
を返すのではなく、ctx.addIssue()
を使用する必要があります。ctx.addIssue
が呼び出されなければバリデーションはパスします。)
superRefineを使ってみる
superRefine
内で、まず、getDuplicateIndexes()
で重複したフォームのインデックスを取得し、そしてaddIssue
にpath: ['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]
});
}
);
});
めっちゃ便利・・・!
コード全体
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]
が効いてくれるようですね。
ちなみに、エラー発火はregister
やtrigger
、そしてaddIssue
のpath
に基づいているようですが、superRefine
自体の実行はuseForm
のtype : 'onChange'
だと、onChange
タイミングで毎回走るようです。
また、superRefine
は返り値が検証ロジックではないため、動的なpath
指定以外にも、同じ前提条件で複数パターンの検証したいときなども便利です。
さいごに
superRefine
を知るきっかけとなり、学びになるいい機会でした。
改善点やもっと便利な使い方など、なにかあれば教えていただけると幸いです。
それでは読んでいただきありがとうございました。
Discussion