← 記事一覧に戻る

Zod + React Hook Form でバリデーションが効かない・エラーメッセージが出ない時の5つの原因と対策【zodResolver】

はじめに

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.cityitems[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 や無関係な場所に出る。

原因

superRefinectx.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 を使う
superRefinepath を指定していない ctx.addIssuepath: ['fieldName'] を指定

最も確実なデバッグ方法は、handleSubmit の onInvalid コールバックにエラーを出力することです。

<form
  onSubmit={handleSubmit(
    (data) => console.log('valid:', data),
    (errors) => console.log('invalid:', errors) // ← エラーの全体像を確認
  )}
>

これで「バリデーションが通っているのかすら分からない」という状況から脱出できます。