😵‍💫

React Hook Formのバリデーションが意図しないタイミングで走って詰まった話

2025/01/15に公開1

概要

自分がReact Hook Form弱者過ぎるのを痛感するこの頃。
今回詰まったのはzodとReact Hook Form管理のフォームの要素に子コンポーネントを介して値を入れた際に全てのフィールドにバリデーションが走ってしまい、迷走しまっくった話です。
この記事が自分と同じようなケースにあたる、あたった人の役に立てば幸いです。
(そもそもの設計が悪いと言われればそれまでですが、、、)

結論

buttonタグにtype="button"をしてすることでこれを回避できる。

スタック

  • Next.js
  • React Hook Form
  • zod

詳細

今回のケースを再現したコードを以下に示します。
まずは大元となるコンポーネントになります。

"use client";

import { useForm, SubmitHandler } from "react-hook-form";
import FormChild from "./FormChild";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";

const exampleSchema = z.object({
	exampleOneChar: z
		.string()
		.nonempty("必須項目です")
		.max(10, "10文字以内で入力して下さい。"),
	exampleThreeChars: z
		.string()
		.nonempty("必須項目です")
		.max(3, "3文字以内です。"),
	childExampleChars: z
		.string()
		.nonempty("必須項目です")
		.max(5, "3文字以内です。"),
});

type exampleType = z.infer<typeof exampleSchema>; // exampleType

export default function PostForm() {
	const {
		register,
		handleSubmit,
		formState: { errors, isValid },
		setValue,
	} = useForm<exampleType>({
		mode: "onChange",
		resolver: zodResolver(exampleSchema),
	});

	const onSubmit: SubmitHandler<exampleType> = (data) => console.log(data);

	return (
		<div className="w-full max-w-xs items-center">
			<form
				className="bg-white shadow-md rounded px-8 pt-6 pb-8 mb-4"
				onSubmit={handleSubmit(onSubmit)}
			>
				<div className="mb-4">
					<label className="block text-gray-700 text-sm font-bold mb-2">
						OneChar
					</label>
					<input
						className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
						type="text"
						{...register("exampleOneChar")}
					/>
					<p className="text-red-500 text-xs italic">
						{errors.exampleOneChar?.message}
					</p>
				</div>
				<div className="mb-6">
					<label className="block text-gray-700 text-sm font-bold mb-2">
						exampleThreeChars
					</label>
					<input
						className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 mb-3 leading-tight focus:outline-none focus:shadow-outline"
						type="text"
						{...register("exampleThreeChars")}
					/>
					<p className="text-red-500 text-xs italic">
						{errors.exampleThreeChars?.message}
					</p>
				</div>
				<div className="mb-6">
					<label className="block text-gray-700 text-sm font-bold mb-2">
						childExampleChars
					</label>
					<input
						className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 mb-3 leading-tight focus:outline-none focus:shadow-outline"
						type="text"
						{...register("childExampleChars")}
					/>
					<p className="text-red-500 text-xs italic">
						{errors.exampleThreeChars?.message}
					</p>
				</div>
                {/* 今回詰まった要因となるこコンポーネント */}
				<FormChild onApply={(text) => setValue("childExampleChars", text)} />
				<div className="flex items-center justify-between">
					<button
						className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline"
						type="submit"
					>
						Submit
					</button>
				</div>
			</form>
		</div>
	);
}

次に問題となる子コンポーネントを示します。

interface FormChildProps {
	onApply: (text: string) => void;
}

const FormChild = (props: FormChildProps) => {
	return (
		<div className="grid grid-rows-2 gap-2 ">
			<div>"Sample"というワードをフィールドに適応します。</div>
			<div>
                {/* ここが問題になるbuttonタグです */}
				<button
					onClick={() => {
						props.onApply("Sample");
					}}
					className="bg-gray-500 hover:bg-gray-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline"
				>
					Apply
				</button>
			</div>
		</div>
	);
};

export default FormChild;

上記のコードのコメントにもあるのですが、子コンポーネントのbuttonにtype指定がないことでApplyボタンをクリックするとsubmitされ、そのタイミングで全てのバリデーションが走っていたようです。

(自分はtype="submit"を入れない限りtype="button"として扱ってくれると思ってました。。。)
そのため、以下のように子コンポーネントのようにbuttonにtype="button" を入れることでバリデーションが全体に走ることを回避することができ、フィールド個別にバリデーションをかけることができます。

interface FormChildProps {
	onApply: (text: string) => void;
}

const FormChild = (props: FormChildProps) => {
	return (
		<div className="grid grid-rows-2 gap-2 ">
			<div>"Sample"というワードをフィールドに適応します。</div>
			<div>
                {/* ここが問題になるbuttonタグです */}
                {/* type="button"を追加 */}
				<button
                    type="button"
					onClick={() => {
						props.onApply("Sample");
					}}
					className="bg-gray-500 hover:bg-gray-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline"
				>
					Apply
				</button>
			</div>
		</div>
	);
};

export default FormChild;


注意点

上記のようなケースの場合、フォームのバリデーションを走らせるパターンやアプリの設計に工夫が必要です。
今回はその辺の言及については避けます。

まとめ

子コンポーネントのボタンクリックを介して親コンポーネントのフォームフィールドに値を入れたい場合は子コンポーネントのbuttonにtype="button"を入れることで意図してない全体のバリデーションを避けることができる。

Discussion

ゆーやゆーや

調べてみるとこの挙動はReact Hook Formではなく、HTMLの機能由来のもので、ドキュメントによるとbuttonのtypeを指定してない場合は以下のような挙動となるらしい。
https://developer.mozilla.org/en-US/docs/Web/HTML/Element/button

  • type未指定かつformタグ内であればsubmitとして働く。
  • type未指定かつformタグ外であればtypeはsubmitだが、挙動はbuttonと同じ挙動をする。

知らなかった...

他の記事でもそもそもbuttonのtypeは明示的に書いた方が良いと言われています。
これから気をつけていきたい。

https://zenn.dev/fujiyama/articles/496e5e81ba7df9
https://qiita.com/tm1ur4/items/3a9027f38536cd16759b