はじめに
Zod と React Hook Form(RHF)を組み合わせてフォームバリデーションを実装する構成は、今や多くのReactプロジェクトで採用されています。しかし「zodResolver を設定したのにバリデーションが動かない」「エラーメッセージが一切表示されない」「送信ボタンを押しても素通りする」というハマりポイントが多く存在します。
この記事では、よくある5つの原因と具体的な解決策をコード例付きで解説します。
症状チェック:こんな症状はありませんか?
errors.xxxが常にundefinedで、エラーメッセージが表示されない- 必須フィールドが空でも
handleSubmitのコールバックが実行される console.log(errors)すると空オブジェクト{}しか出ない- 入力中はエラーが出るが、送信時には検証されない(またはその逆)
- ネストされたフォームフィールド(配列・オブジェクト)だけバリデーションが効かない
superRefineでカスタムバリデーションを書いたが、エラーがフィールドに紐付かない
原因1: zodResolver のインポートパスが間違っている
症状
設定しているはずなのに errors が全て空。コンソールにも何も出ない。
原因
@hookform/resolvers のバージョンによってインポートパスが変わっています。v3以降は以下が正しいパスです。
// ❌ 古いパス(v2以前)
import { zodResolver } from '@hookform/resolvers/zod/dist/zod'
// ✅ 正しいパス(v3以降)
import { zodResolver } from '@hookform/resolvers/zod'
また、resolver に渡し忘れているケースも多いです。
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
const schema = z.object({
email: z.string().email('有効なメールアドレスを入力してください'),
password: z.string().min(8, 'パスワードは8文字以上で入力してください'),
})
type FormValues = z.infer<typeof schema>
// ❌ resolver を渡していない
const { register, handleSubmit, formState: { errors } } = useForm<FormValues>()
// ✅ resolver を渡す
const { register, handleSubmit, formState: { errors } } = useForm<FormValues>({
resolver: zodResolver(schema),
})
対処法
@hookform/resolvers のバージョンを確認して、正しいインポートパスを使う。
npm ls @hookform/resolvers
原因2: mode の設定がユースケースに合っていない
症状
「入力中はエラーが出ない」「送信しないとエラーが分からない」「逆に入力のたびにエラーが走って重い」
原因
RHF の mode オプションはバリデーションのタイミングを制御します。デフォルトは "onSubmit" で、送信時のみ検証が走ります。
| mode | バリデーションタイミング |
|---|---|
"onSubmit"(デフォルト) |
送信ボタンを押した時のみ |
"onChange" |
入力値が変わるたびに |
"onBlur" |
フォーカスが外れた時 |
"onTouched" |
最初のblur後はonChangeでも |
"all" |
onChangeとonBlur両方 |
対処法
UX要件に合った mode を設定する。
const { register, handleSubmit, formState: { errors } } = useForm<FormValues>({
resolver: zodResolver(schema),
// 入力中にリアルタイムで検証したい場合
mode: 'onChange',
// フォーカスが外れた時に検証(おすすめ)
// mode: 'onBlur',
})
また、一度送信した後は自動的に "onChange" モードに切り替わる仕様があります(reValidateMode で制御可能)。
const { register, handleSubmit, formState: { errors } } = useForm<FormValues>({
resolver: zodResolver(schema),
mode: 'onBlur',
reValidateMode: 'onChange', // 送信後は onChange でリアルタイム検証
})
原因3: Zod スキーマの型と register のフィールド名が合っていない
症状
特定のフィールドだけ errors が出ない。スキーマは正しいはずなのに検証されない。
原因
register() に渡すフィールド名が Zod スキーマのキー名と一致していない場合、そのフィールドの検証が無視されます。
const schema = z.object({
// スキーマのキーは "email"
email: z.string().email(),
})
// ❌ フィールド名が "emailAddress" になっている(スキーマと不一致)
<input {...register('emailAddress')} />
// ✅ スキーマのキー名と完全一致させる
<input {...register('email')} />
TypeScriptを正しく使っていれば型エラーで気づけますが、useForm の型引数を省略すると見逃します。
// ❌ 型引数なし(フィールド名の誤りを検出できない)
const { register } = useForm({ resolver: zodResolver(schema) })
// ✅ z.infer で型を渡す(フィールド名のミスをコンパイル時に検出)
type FormValues = z.infer<typeof schema>
const { register } = useForm<FormValues>({ resolver: zodResolver(schema) })
対処法
必ず z.infer<typeof schema> で型を生成して useForm<FormValues> に渡すこと。型引数があれば register('emailAddress') は型エラーになるため、実行前に気づける。
原因4: ネストされたオブジェクト・配列フィールドのアクセス方法が間違っている
症状
ネストされたフィールド(address.city や items[0].name など)だけバリデーションが効かない・エラーが表示されない。
原因
RHF でネストされたフィールドにアクセスするには、ドット記法や角括弧記法を使います。エラーオブジェクトへのアクセス方法も同様です。
const schema = z.object({
address: z.object({
city: z.string().min(1, '市区町村を入力してください'),
zipCode: z.string().regex(/^d{7}$/, '郵便番号は7桁の数字で入力してください'),
}),
})
type FormValues = z.infer<typeof schema>
// ✅ ネストされたフィールドの register
<input {...register('address.city')} />
<input {...register('address.zipCode')} />
// ✅ ネストされたエラーへのアクセス
{errors.address?.city && <p>{errors.address.city.message}</p>}
{errors.address?.zipCode && <p>{errors.address.zipCode.message}</p>}
配列フィールドの場合は useFieldArray を使います。
const schema = z.object({
items: z.array(
z.object({
name: z.string().min(1, '名前を入力してください'),
quantity: z.number().min(1, '数量は1以上を入力してください'),
})
).min(1, '1件以上入力してください'),
})
type FormValues = z.infer<typeof schema>
function ItemForm() {
const { register, control, formState: { errors } } = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { items: [{ name: '', quantity: 1 }] },
})
const { fields, append, remove } = useFieldArray({ control, name: 'items' })
return (
<>
{fields.map((field, index) => (
<div key={field.id}>
<input {...register(`items.${index}.name`)} />
{errors.items?.[index]?.name && (
<p>{errors.items[index].name.message}</p>
)}
</div>
))}
{/* 配列全体のエラー(min(1)違反など) */}
{errors.items?.root && <p>{errors.items.root.message}</p>}
</>
)
}
原因5: superRefine / refine のエラーがフィールドに紐付かない
症状
パスワード確認などのクロスフィールドバリデーションを superRefine で実装したが、エラーメッセージが errors.confirmPassword ではなく errors.root や無関係な場所に出る。
原因
superRefine で ctx.addIssue する際に path を正しく指定しないと、エラーがフォームのルートに付き、特定のフィールドに紐付きません。
// ❌ path を指定しない(エラーがルートに付く)
const schema = z.object({
password: z.string().min(8),
confirmPassword: z.string(),
}).superRefine((data, ctx) => {
if (data.password !== data.confirmPassword) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'パスワードが一致しません',
// path がないのでフィールドに紐付かない
})
}
})
// ✅ path を指定してフィールドに紐付ける
const schema = z.object({
password: z.string().min(8, 'パスワードは8文字以上で入力してください'),
confirmPassword: z.string(),
}).superRefine((data, ctx) => {
if (data.password !== data.confirmPassword) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'パスワードが一致しません',
path: ['confirmPassword'], // ← 紐付けたいフィールド名を指定
})
}
})
.refine() を使う場合は第2引数の path で指定します。
const schema = z.object({
password: z.string().min(8),
confirmPassword: z.string(),
}).refine(
(data) => data.password === data.confirmPassword,
{
message: 'パスワードが一致しません',
path: ['confirmPassword'], // エラーを表示するフィールド
}
)
対処法
superRefine / refine を使う際は必ず path にエラーを紐付けるフィールド名を指定する。紐付けた後のエラーアクセスは通常通り。
{errors.confirmPassword && (
<p className="text-red-500">{errors.confirmPassword.message}</p>
)}
ボーナス: defaultValues がないと数値型フィールドでハマる
Zod で z.number() を使っているフィールドは、HTML <input> が返す値が文字列のため、型不一致が起きます。
const schema = z.object({
age: z.number().min(0).max(120),
})
// ❌ 文字列 "25" が来て number バリデーションが失敗する
<input type="number" {...register('age')} />
// ✅ valueAsNumber を使って数値に変換する
<input type="number" {...register('age', { valueAsNumber: true })} />
または Zod 側で z.coerce.number() を使うことで文字列からの変換を自動化できます。
const schema = z.object({
// 文字列を自動で数値に変換してからバリデーション
age: z.coerce.number().min(0).max(120),
})
まとめ
Zod + React Hook Form のバリデーションが効かない原因まとめ:
| 原因 | 対処法 |
|---|---|
zodResolver のインポートパス・渡し忘れ |
@hookform/resolvers/zod から正しくインポートし、resolver に渡す |
mode 設定がユースケースに合っていない |
mode: 'onBlur' や mode: 'onChange' を適切に設定 |
| フィールド名とスキーマのキー名が不一致 | z.infer で型を生成し useForm<FormValues> に渡す |
| ネストされたフィールドのアクセス方法が間違い | ドット記法・配列には useFieldArray を使う |
superRefine で path を指定していない |
ctx.addIssue に path: ['fieldName'] を指定 |
最も確実なデバッグ方法は、handleSubmit の onInvalid コールバックにエラーを出力することです。
<form
onSubmit={handleSubmit(
(data) => console.log('valid:', data),
(errors) => console.log('invalid:', errors) // ← エラーの全体像を確認
)}
>
これで「バリデーションが通っているのかすら分からない」という状況から脱出できます。
