mcpField
R
react-hook-form-zod

react-hook-form-zod

github.com/ovachiever開発56723 回インストール49 週間前に更新
リポジトリ

react-hook-form-zod について

React Hook Form と Zod を使ったフォーム開発の実践ガイドです。スキーマ検証、TypeScript の型推論、フィールドの実装パターン、応用的なフォーム構成を扱います。

主な機能

  1. 01zodResolver による Zod スキーマ検証
  2. 02z.infer による型推論
  3. 03標準フィールドと制御フィールドのパターン
  4. 04動的フォームと複数ステップフォーム
  5. 05エラー処理とアクセシビリティの指針

活用シーン

  1. 01共通スキーマによるフォーム項目の検証
  2. 02カスタム入力と React Hook Form の接続
  3. 03条件付きまたは繰り返し項目のあるフォーム作成

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/ovachiever/droid-tings --skill react-hook-form-zod

インストール後、Claude Code で/react-hook-form-zodを入力して読み込まれたか確認します。

同梱ファイル

SKILL.md と一緒にインストールされる参考資料やスクリプト。

README.md
.claude-plugin/
plugin.json
references/
accessibility.md
error-handling.md
links-to-official-docs.md
performance-optimization.md
rhf-api-reference.md
shadcn-integration.md
top-errors.md
zod-schemas-guide.md
scripts/
check-versions.sh
templates/
advanced-form.tsx
async-validation.tsx
basic-form.tsx
custom-error-display.tsx
dynamic-fields.tsx
multi-step-form.tsx
package.json
server-validation.ts
shadcn-form.tsx

詳細

React Hook Form + Zod によるバリデーション

ステータス:本番環境で利用可能 ✅ 最終更新日:2025-11-20 依存関係:なし(単独で利用可能) 最新バージョン:[email protected]、[email protected]、@hookform/[email protected]


クイックスタート(10 分)

1. パッケージをインストールする
npm install [email protected] [email protected] @hookform/[email protected]

これらのパッケージを使う理由:

  • react-hook-form:再レンダリングを最小限に抑えた、高性能で柔軟なフォームライブラリです
  • zod:型推論に対応した、TypeScript を第一に考えたスキーマバリデーションライブラリです
  • @hookform/resolvers:Zod(およびその他のバリデーター)を React Hook Form に接続するアダプターです
2. 最初のフォームを作成する
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'

// 1. Define validation schema
const loginSchema = z.object({
  email: z.string().email('Invalid email address'),
  password: z.string().min(8, 'Password must be at least 8 characters'),
})

// 2. Infer TypeScript type from schema
type LoginFormData = z.infer<typeof loginSchema>

function LoginForm() {
  // 3. Initialize form with zodResolver
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<LoginFormData>({
    resolver: zodResolver(loginSchema),
    defaultValues: {
      email: '',
      password: '',
    },
  })

  // 4. Handle form submission
  const onSubmit = async (data: LoginFormData) => {
    // Data is guaranteed to be valid here
    console.log('Valid data:', data)
    // Make API call, etc.
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <label htmlFor="email">Email</label>
        <input id="email" type="email" {...register('email')} />
        {errors.email && (
          <span role="alert" className="error">
            {errors.email.message}
          </span>
        )}
      </div>

      <div>
        <label htmlFor="password">Password</label>
        <input id="password" type="password" {...register('password')} />
        {errors.password && (
          <span role="alert" className="error">
            {errors.password.message}
          </span>
        )}
      </div>

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Logging in...' : 'Login'}
      </button>
    </form>
  )
}

重要:

  • 「uncontrolled to controlled」の警告を防ぐため、必ず defaultValues を設定してください
  • Zod バリデーションを接続するには、zodResolver(schema) を使用してください
  • 完全な型安全性を確保するには、z.infer<typeof schema> でフォームの型を指定してください
  • クライアントとサーバーの両方でバリデーションしてください(クライアント側のバリデーションだけを信頼しないでください)
3. サーバー側のバリデーションを追加する
// server/api/login.ts
import { z } from 'zod'

// SAME schema on server
const loginSchema = z.object({
  email: z.string().email('Invalid email address'),
  password: z.string().min(8, 'Password must be at least 8 characters'),
})

export async function loginHandler(req: Request) {
  try {
    // Parse and validate request body
    const data = loginSchema.parse(await req.json())

    // Data is type-safe and validated
    // Proceed with authentication logic
    return { success: true }
  } catch (error) {
    if (error instanceof z.ZodError) {
      // Return validation errors to client
      return { success: false, errors: error.flatten().fieldErrors }
    }
    throw error
  }
}

サーバー側でバリデーションする理由:

  • クライアント側のバリデーションは回避できます(要素の検証、Postman、curl など)
  • サーバー側のバリデーションはセキュリティ層となります
  • 同じ Zod スキーマを使うことで、信頼できる唯一の情報源を維持できます
  • フロントエンドとバックエンド全体で型安全性を確保できます

基本概念

useForm フックの構成
const {
  register,           // Register input fields
  handleSubmit,       // Wrap onSubmit handler
  watch,              // Watch field values
  formState,          // Form state (errors, isValid, isDirty, etc.)
  setValue,           // Set field value programmatically
  getValues,          // Get current form values
  reset,              // Reset form to defaults
  trigger,            // Trigger validation manually
  control,            // Control object for Controller/useController
} = useForm<FormData>({
  resolver: zodResolver(schema),  // Validation resolver
  mode: 'onSubmit',               // When to validate (onSubmit, onChange, onBlur, all)
  defaultValues: {},              // Initial values (REQUIRED for controlled inputs)
})

useForm のオプション:

オプション説明デフォルト
resolverバリデーションリゾルバー(例:zodResolver)undefined
modeバリデーションを行うタイミング('onSubmit'、'onChange'、'onBlur'、'all')'onSubmit'
reValidateModeエラー発生後に再バリデーションを行うタイミング'onChange'
defaultValuesフォームの初期値{}
shouldUnregisterアンマウント時に入力項目の登録を解除するかどうかfalse
criteriaModeすべてのエラーを返すか、最初のエラーのみを返すか'firstError'

フォームのバリデーションモード:

  • onSubmit - 送信時にバリデーションします(パフォーマンスは最高ですが、応答性は低くなります)
  • onChange - 変更のたびにバリデーションします(リアルタイムにフィードバックできますが、再レンダリングが増えます)
  • onBlur - フィールドからフォーカスが外れたときにバリデーションします(バランスに優れています)
  • all - 送信時、フォーカスが外れたとき、変更時にバリデーションします(応答性は最も高く、コストも最大です)
Zod スキーマの定義
import { z } from 'zod'

// Primitives
const stringSchema = z.string()
const numberSchema = z.number()
const booleanSchema = z.boolean()
const dateSchema = z.date()

// With validation
const emailSchema = z.string().email('Invalid email')
const ageSchema = z.number().min(18, 'Must be 18+').max(120, 'Invalid age')
const usernameSchema = z.string().min(3).max(20).regex(/^[a-zA-Z0-9_]+$/)

// Objects
const userSchema = z.object({
  name: z.string(),
  email: z.string().email(),
  age: z.number().int().positive(),
})

// Arrays
const tagsSchema = z.array(z.string())
const usersSchema = z.array(userSchema)

// Optional and Nullable
const optionalField = z.string().optional()       // string | undefined
const nullableField = z.string().nullable()       // string | null
const nullishField = z.string().nullish()         // string | null | undefined

// Default values
const withDefault = z.string().default('default value')

// Unions
const statusSchema = z.union([
  z.literal('active'),
  z.literal('inactive'),
  z.literal('pending'),
])
// Shorthand for literals
const statusEnum = z.enum(['active', 'inactive', 'pending'])

// Nested objects
const addressSchema = z.object({
  street: z.string(),
  city: z.string(),
  zipCode: z.string().regex(/^\d{5}$/),
})

const profileSchema = z.object({
  name: z.string(),
  address: addressSchema,  // Nested object
})

// Custom error messages
const passwordSchema = z.string()
  .min(8, { message: 'Password must be at least 8 characters' })
  .regex(/[A-Z]/, { message: 'Password must contain uppercase letter' })
  .regex(/[0-9]/, { message: 'Password must contain number' })

型推論:

const userSchema = z.object({
  name: z.string(),
  age: z.number(),
})

// Automatically infer TypeScript type
type User = z.infer<typeof userSchema>
// Result: { name: string; age: number }
Zod の Refinement(カスタムバリデーション)
// Simple refinement
const passwordConfirmSchema = z.object({
  password: z.string().min(8),
  confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
  message: "Passwords don't match",
  path: ['confirmPassword'], // Error will appear on confirmPassword field
})

// Multiple refinements
const signupSchema = z.object({
  username: z.string(),
  email: z.string().email(),
  age: z.number(),
})
  .refine((data) => data.username !== data.email.split('@')[0], {
    message: 'Username cannot be your email prefix',
    path: ['username'],
  })
  .refine((data) => data.age >= 18, {
    message: 'Must be 18 or older',
    path: ['age'],
  })

// Async refinement (for API checks)
const usernameSchema = z.string().refine(async (username) => {
  // Check if username is available via API
  const response = await fetch(`/api/check-username?username=${username}`)
  const { available } = await response.json()
  return available
}, {
  message: 'Username is already taken',
})
Zod の Transform(データ操作)
// Transform string to number
const ageSchema = z.string().transform((val) => parseInt(val, 10))

// Transform to uppercase
const uppercaseSchema = z.string().transform((val) => val.toUpperCase())

// Transform date string to Date object
const dateSchema = z.string().transform((val) => new Date(val))

// Trim whitespace
const trimmedSchema = z.string().transform((val) => val.trim())

// Complex transform
const userInputSchema = z.object({
  email: z.string().email().transform((val) => val.toLowerCase()),
  tags: z.string().transform((val) => val.split(',').map(tag => tag.trim())),
})

// Chain transform and refine
const positiveNumberSchema = z.string()
  .transform((val) => parseFloat(val))
  .refine((val) => !isNaN(val), { message: 'Must be a number' })
  .refine((val) => val > 0, { message: 'Must be positive' })
zodResolver の統合
import { zodResolver } from '@hookform/resolvers/zod'

const form = useForm<FormData>({
  resolver: zodResolver(schema),
})

zodResolver の役割:

  1. Zod スキーマを受け取ります
  2. React Hook Form が理解できる形式に変換します
  3. フォーム送信時に実行されるバリデーション関数を提供します
  4. Zod のエラーを React Hook Form のエラー形式に対応付けます
  5. TypeScript の型推論を使って型安全性を維持します

zodResolver のオプション:

import { zodResolver } from '@hookform/resolvers/zod'

// With options
const form = useForm({
  resolver: zodResolver(schema, {
    async: false,     // Use async validation
    raw: false,       // Return raw Zod error
  }),
})

フォーム登録のパターン

パターン 1:シンプルな入力の登録
function BasicForm() {
  const { register, handleSubmit } = useForm<FormData>({
    resolver: zodResolver(schema),
  })

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      {/* Spread register result to input */}
      <input {...register('email')} />
      <input {...register('password')} />

      {/* With custom props */}
      <input
        {...register('username')}
        placeholder="Enter username"
        className="input"
      />
    </form>
  )
}

register() が返すもの:

{
  onChange: (e) => void,
  onBlur: (e) => void,
  ref: (instance) => void,
  name: string,
}
パターン 2:Controller(カスタムコンポーネント向け)

入力が ref を公開しない場合(カスタムコンポーネント、React Select、日付ピッカーなど)は、Controller を使用します。

import { Controller } from 'react-hook-form'

function FormWithCustomInput() {
  const { control, handleSubmit } = useForm<FormData>({
    resolver: zodResolver(schema),
  })

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <Controller
        name="category"
        control={control}
        render={({ field }) => (
          <CustomSelect
            {...field}                      // value, onChange, onBlur, ref
            options={categoryOptions}
          />
        )}
      />

      {/* With more control */}
      <Controller
        name="dateOfBirth"
        control={control}
        render={({ field, fieldState }) => (
          <div>
            <DatePicker
              selected={field.value}
              onChange={field.onChange}
              onBlur={field.onBlur}
            />
            {fieldState.error && (
              <span>{fieldState.error.message}</span>
            )}
          </div>
        )}
      />
    </form>
  )
}

Controller を使う場面:

  • ✅ サードパーティ製の UI ライブラリ(React Select、Material-UI、Ant Design など)
  • ✅ ref を公開しないカスタムコンポーネント
  • ✅ onChange を使用しないコンポーネント(独自のハンドラーを使うチェックボックスなど)
  • ✅ フィールドの動作を細かく制御する必要がある場合

Controller を使わない場面:

  • ❌ 標準的な HTML 入力要素(よりシンプルで高速な register を使用してください)
  • ❌ パフォーマンスが最優先の場合(Controller によるオーバーヘッドは最小限です)
パターン 3:useController(再利用可能な制御付き入力)
import { useController } from 'react-hook-form'

// Reusable custom input component
function CustomInput({ name, control, label }) {
  const {
    field,
    fieldState: { error },
  } = useController({
    name,
    control,
    defaultValue: '',
  })

  return (
    <div>
      <label>{label}</label>
      <input {...field} />
      {error && <span>{error.message}</span>}
    </div>
  )
}

// Usage
function MyForm() {
  const { control, handleSubmit } = useForm({
    resolver: zodResolver(schema),
  })

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <CustomInput name="email" control={control} label="Email" />
      <CustomInput name="username" control={control} label="Username" />
    </form>
  )
}

エラー処理

エラーの表示
function FormWithErrors() {
  const { register, handleSubmit, formState: { errors } } = useForm<FormData>({
    resolver: zodResolver(schema),
  })

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <input {...register('email')} aria-invalid={errors.email ? 'true' : 'false'} />

        {/* Simple error display */}
        {errors.email && <span>{errors.email.message}</span>}

        {/* Accessible error display */}
        {errors.email && (
          <span role="alert" className="error">
            {errors.email.message}
          </span>
        )}

        {/* Error with icon */}
        {errors.email && (
          <div role="alert" className="error">
            <ErrorIcon />
            <span>{errors.email.message}</span>
          </div>
        )}
      </div>
    </form>
  )
}
エラーオブジェクトの構造
// errors object structure
{
  email: {
    type: 'invalid_string',
    message: 'Invalid email address',
  },
  password: {
    type: 'too_small',
    message: 'Password must be at least 8 characters',
  },
  // Nested errors
  address: {
    street: {
      type: 'invalid_type',
      message: 'Expected string, received undefined',
    },
  },
}
フォームレベルのバリデーションエラー
const schema = z.object({
  password: z.string().min(8),
  confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
  message: "Passwords don't match",
  path: ['confirmPassword'], // Attach error to confirmPassword field
})

// Without path - creates root error
.refine((data) => someCondition, {
  message: 'Form validation failed',
})

// Access root errors
const { formState: { errors } } = useForm()
errors.root?.message // Root-level error
サーバーエラーの統合
function FormWithServerErrors() {
  const { register, handleSubmit, setError, formState: { errors } } = useForm({
    resolver: zodResolver(schema),
  })

  const onSubmit = async (data) => {
    try {
      const response = await fetch('/api/submit', {
        method: 'POST',
        body: JSON.stringify(data),
      })

      if (!response.ok) {
        const { errors: serverErrors } = await response.json()

        // Map server errors to form fields
        Object.entries(serverErrors).forEach(([field, message]) => {
          setError(field, {
            type: 'server',
            message,
          })
        })

        return
      }

      // Success!
    } catch (error) {
      // Generic error
      setError('root', {
        type: 'server',
        message: 'An error occurred. Please try again.',
      })
    }
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      {errors.root && <div role="alert">{errors.root.message}</div>}
      {/* ... */}
    </form>
  )
}

高度なパターン

動的なフォームフィールド(useFieldArray)
import { useForm, useFieldArray } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'

const contactSchema = z.object({
  contacts: z.array(
    z.object({
      name: z.string().min(1, 'Name is required'),
      email: z.string().email('Invalid email'),
    })
  ).min(1, 'At least one contact is required'),
})

type ContactFormData = z.infer<typeof contactSchema>

function ContactListForm() {
  const { register, control, handleSubmit, formState: { errors } } = useForm<ContactFormData>({
    resolver: zodResolver(contactSchema),
    defaultValues: {
      contacts: [{ name: '', email: '' }],
    },
  })

  const { fields, append, remove } = useFieldArray({
    control,
    name: 'contacts',
  })

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      {fields.map((field, index) => (
        <div key={field.id}> {/* IMPORTANT: Use field.id, not index */}
          <input
            {...register(`contacts.${index}.name` as const)}
            placeholder="Name"
          />
          {errors.contacts?.[index]?.name && (
            <span>{errors.contacts[index].name.message}</span>
          )}

          <input
            {...register(`contacts.${index}.email` as const)}
            placeholder="Email"
          />
          {errors.contacts?.[index]?.email && (
            <span>{errors.contacts[index].email.message}</span>
          )}

          <button type="button" onClick={() => remove(index)}>
            Remove
          </button>
        </div>
      ))}

      <button
        type="button"
        onClick={() => append({ name: '', email: '' })}
      >
        Add Contact
      </button>

      <button type="submit">Submit</button>
    </form>
  )
}

useFieldArray API:

  • fields - 一意の ID を持つフィールド項目の配列
  • append(value) - 末尾に新しい項目を追加します
  • prepend(value) - 先頭に新しい項目を追加します
  • insert(index, value) - 指定した位置に項目を挿入します
  • remove(index) - 指定した位置の項目を削除します
  • update(index, value) - 指定した位置の項目を更新します
  • replace(values) - 配列全体を置き換えます
デバウンスを使用した非同期バリデーション
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { useDebouncedCallback } from 'use-debounce' // npm install use-debounce

const usernameSchema = z.string().min(3).refine(async (username) => {
  const response = await fetch(`/api/check-username?username=${username}`)
  const { available } = await response.json()
  return available
}, {
  message: 'Username is already taken',
})

function AsyncValidationForm() {
  const { register, handleSubmit, trigger, formState: { errors, isValidating } } = useForm({
    resolver: zodResolver(z.object({ username: usernameSchema })),
    mode: 'onChange', // Validate on every change
  })

  // Debounce validation to avoid too many API calls
  const debouncedValidation = useDebouncedCallback(() => {
    trigger('username')
  }, 500) // Wait 500ms after user stops typing

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input
        {...register('username')}
        onChange={(e) => {
          register('username').onChange(e)
          debouncedValidation()
        }}
      />
      {isValidating && <span>Checking availability...</span>}
      {errors.username && <span>{errors.username.message}</span>}
    </form>
  )
}
複数ステップフォーム(ウィザード)
import { useState } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'

// Step schemas
const step1Schema = z.object({
  name: z.string().min(1, 'Name is required'),
  email: z.string().email('Invalid email'),
})

const step2Schema = z.object({
  address: z.string().min(1, 'Address is required'),
  city: z.string().min(1, 'City is required'),
})

const step3Schema = z.object({
  cardNumber: z.string().regex(/^\d{16}$/, 'Invalid card number'),
  cvv: z.string().regex(/^\d{3,4}$/, 'Invalid CVV'),
})

// Combined schema for final validation
const fullSchema = step1Schema.merge(step2Schema).merge(step3Schema)

type FormData = z.infer<typeof fullSchema>

function MultiStepForm() {
  const [step, setStep] = useState(1)

  const { register, handleSubmit, trigger, formState: { errors } } = useForm<FormData>({
    resolver: zodResolver(fullSchema),
    mode: 'onChange',
  })

  const nextStep = async () => {
    let fieldsToValidate: (keyof FormData)[] = []

    if (step === 1) {
      fieldsToValidate = ['name', 'email']
    } else if (step === 2) {
      fieldsToValidate = ['address', 'city']
    }

    // Validate current step fields
    const isValid = await trigger(fieldsToValidate)

    if (isValid) {
      setStep(step + 1)
    }
  }

  const prevStep = () => setStep(step - 1)

  const onSubmit = (data: FormData) => {
    console.log('Final data:', data)
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      {/* Progress indicator */}
      <div className="progress">
        Step {step} of 3
      </div>

      {/* Step 1 */}
      {step === 1 && (
        <div>
          <h2>Personal Information</h2>
          <input {...register('name')} placeholder="Name" />
          {errors.name && <span>{errors.name.message}</span>}

          <input {...register('email')} placeholder="Email" />
          {errors.email && <span>{errors.email.message}</span>}
        </div>
      )}

      {/* Step 2 */}
      {step === 2 && (
        <div>
          <h2>Address</h2>
          <input {...register('address')} placeholder="Address" />
          {errors.address && <span>{errors.address.message}</span>}

          <input {...register('city')} placeholder="City" />
          {errors.city && <span>{errors.city.message}</span>}
        </div>
      )}

      {/* Step 3 */}
      {step === 3 && (
        <div>
          <h2>Payment</h2>
          <input {...register('cardNumber')} placeholder="Card Number" />
          {errors.cardNumber && <span>{errors.cardNumber.message}</span>}

          <input {...register('cvv')} placeholder="CVV" />
          {errors.cvv && <span>{errors.cvv.message}</span>}
        </div>
      )}

      {/* Navigation */}
      <div>
        {step > 1 && (
          <button type="button" onClick={prevStep}>
            Previous
          </button>
        )}
        {step < 3 ? (
          <button type="button" onClick={nextStep}>
            Next
          </button>
        ) : (
          <button type="submit">Submit</button>
        )}
      </div>
    </form>
  )
}
条件付きバリデーション
import { z } from 'zod'

// Schema with conditional validation
const formSchema = z.discriminatedUnion('accountType', [
  z.object({
    accountType: z.literal('personal'),
    name: z.string().min(1),
  }),
  z.object({
    accountType: z.literal('business'),
    companyName: z.string().min(1),
    taxId: z.string().regex(/^\d{9}$/),
  }),
])

// Alternative: Using refine
const conditionalSchema = z.object({
  hasDiscount: z.boolean(),
  discountCode: z.string().optional(),
}).refine((data) => {
  // If hasDiscount is true, discountCode is required
  if (data.hasDiscount && !data.discountCode) {
    return false
  }
  return true
}, {
  message: 'Discount code is required when discount is enabled',
  path: ['discountCode'],
})

shadcn/ui の統合

Form コンポーネントを使用する(レガシー)
import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import {
  Form,
  FormControl,
  FormDescription,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'

const formSchema = z.object({
  username: z.string().min(2, 'Username must be at least 2 characters'),
  email: z.string().email('Invalid email address'),
})

function ProfileForm() {
  const form = useForm<z.infer<typeof formSchema>>({
    resolver: zodResolver(formSchema),
    defaultValues: {
      username: '',
      email: '',
    },
  })

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
        <FormField
          control={form.control}
          name="username"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Username</FormLabel>
              <FormControl>
                <Input placeholder="shadcn" {...field} />
              </FormControl>
              <FormDescription>
                This is your public display name.
              </FormDescription>
              <FormMessage />
            </FormItem>
          )}
        />

        <FormField
          control={form.control}
          name="email"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Email</FormLabel>
              <FormControl>
                <Input type="email" placeholder="[email protected]" {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />

        <button type="submit">Submit</button>
      </form>
    </Form>
  )
}

注:shadcn/ui は「Form コンポーネントは現在、積極的に開発していません」と述べています。新しい実装では Field コンポーネントを使用することを推奨しています。

Field コンポーネントを使用する(推奨)

Field コンポーネントは現在も積極的にメンテナンスされている方法です。最新の Field コンポーネント API については、shadcn/ui のドキュメントを確認してください。


パフォーマンスの最適化

フォームモードの戦略
// Best performance - validate only on submit
const form = useForm({
  mode: 'onSubmit',
  resolver: zodResolver(schema),
})

// Good balance - validate on blur
const form = useForm({
  mode: 'onBlur',
  resolver: zodResolver(schema),
})

// Live feedback - validate on every change
const form = useForm({
  mode: 'onChange',
  resolver: zodResolver(schema),
})

// Maximum validation - all events
const form = useForm({
  mode: 'all',
  resolver: zodResolver(schema),
})
制御された入力と制御されていない入力
// Uncontrolled (better performance) - use register
<input {...register('email')} />

// Controlled (more control) - use Controller
<Controller
  name="email"
  control={control}
  render={({ field }) => <Input {...field} />}
/>

推奨:標準的な入力には register を使用し、必要な場合(サードパーティ製コンポーネントやカスタム動作)に限り Controller を使用してください。

Controller による分離
// BAD: Entire form re-renders when any field changes
function BadForm() {
  const { watch } = useForm()
  const values = watch() // Watches ALL fields

  return <div>{JSON.stringify(values)}</div>
}

// GOOD: Only re-render when specific field changes
function GoodForm() {
  const { watch } = useForm()
  const email = watch('email') // Watches only email field

  return <div>{email}</div>
}
shouldUnregister フラグ
const form = useForm({
  resolver: zodResolver(schema),
  shouldUnregister: true, // Remove field data when unmounted
})

使用する場合:

  • ✅ 各ステップで異なるフィールドを使用する複数ステップフォーム
  • ✅ 値を保持しない条件付きフィールド
  • ✅ コンポーネントのアンマウント時にデータをクリアする場合

使用しない場合:

  • ❌ 表示を切り替えてもフォームデータを保持する場合
  • ❌ フォームのセクション(タブ、アコーディオン)間を移動する場合

アクセシビリティのベストプラクティス

ARIA 属性
function AccessibleForm() {
  const { register, handleSubmit, formState: { errors } } = useForm({
    resolver: zodResolver(schema),
  })

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <label htmlFor="email">Email</label>
        <input
          id="email"
          {...register('email')}
          aria-invalid={errors.email ? 'true' : 'false'}
          aria-describedby={errors.email ? 'email-error' : undefined}
        />
        {errors.email && (
          <span id="email-error" role="alert">
            {errors.email.message}
          </span>
        )}
      </div>
    </form>
  )
}
エラーの通知
import { useEffect } from 'react'

function FormWithAnnouncements() {
  const { formState: { errors, isSubmitted } } = useForm()

  // Announce errors to screen readers
  useEffect(() => {
    if (isSubmitted && Object.keys(errors).length > 0) {
      const errorCount = Object.keys(errors).length
      const announcement = `Form submission failed with ${errorCount} error${errorCount > 1 ? 's' : ''}`

      // Create live region for announcement
      const liveRegion = document.createElement('div')
      liveRegion.setAttribute('role', 'alert')
      liveRegion.setAttribute('aria-live', 'assertive')
      liveRegion.textContent = announcement
      document.body.appendChild(liveRegion)

      setTimeout(() => {
        document.body.removeChild(liveRegion)
      }, 1000)
    }
  }, [errors, isSubmitted])

  return (
    <form>
      {/* ... */}
    </form>
  )
}
フォーカス管理
import { useRef, useEffect } from 'react'

function FormWithFocus() {
  const { handleSubmit, formState: { errors } } = useForm()
  const firstErrorRef = useRef<HTMLInputElement>(null)

  // Focus first error field on validation failure
  useEffect(() => {
    if (Object.keys(errors).length > 0) {
      firstErrorRef.current?.focus()
    }
  }, [errors])

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input
        {...register('email')}
        ref={errors.email ? firstErrorRef : undefined}
      />
    </form>
  )
}

重要なルール

必ず行うこと

✅ defaultValues を設定して「uncontrolled から controlled への変更」に関する警告を防ぐ

const form = useForm({
  defaultValues: { email: '', password: '' }, // ALWAYS set defaults
})

✅ Zod との統合には zodResolver を使用する

const form = useForm({
  resolver: zodResolver(schema), // Required for Zod validation
})

✅ z.infer でフォームに型を付ける

type FormData = z.infer<typeof schema> // Automatic type inference

✅ クライアントとサーバーの両方でバリデーションを行う

// Client
const form = useForm({ resolver: zodResolver(schema) })

// Server
const data = schema.parse(await req.json()) // SAME schema

✅ エラーの表示には formState.errors を使用する

{errors.email && <span role="alert">{errors.email.message}</span>}

✅ アクセシビリティのために ARIA 属性を追加する

<input
  {...register('email')}
  aria-invalid={errors.email ? 'true' : 'false'}
  aria-describedby="email-error"
/>

✅ useFieldArray のキーには field.id を使用する

{fields.map((field) => <div key={field.id}>{/* ... */}</div>)}

✅ 非同期バリデーションをデバウンスする

const debouncedValidation = useDebouncedCallback(() => trigger('username'), 500)
絶対に行わないこと

❌ サーバー側のバリデーションを省略する(セキュリティ上の脆弱性につながります!)

// BAD: Only client validation
const form = useForm({ resolver: zodResolver(schema) })
// API endpoint has no validation

// GOOD: Validate on both client and server
const form = useForm({ resolver: zodResolver(schema) })
// API: schema.parse(data) on server too

❌ 型推論を確認せずに Zod v4 を使用する

// Issue #13109: Zod v4 has type inference changes
// Test your types carefully when upgrading

❌ Controller で {...field} を展開し忘れる

// BAD
<Controller render={({ field }) => <Input value={field.value} />} />

// GOOD
<Controller render={({ field }) => <Input {...field} />} />

❌ フォームの値を直接変更する

// BAD
const values = getValues()
values.email = '[email protected]' // Direct mutation

// GOOD
setValue('email', '[email protected]') // Use setValue

❌ デバウンスせずにインラインバリデーションを使用する

// BAD: Validates on every keystroke
const form = useForm({ mode: 'onChange' })

// GOOD: Debounce async validation
const debouncedTrigger = useDebouncedCallback(() => trigger(), 500)

❌ 制御された入力と制御されていない入力を混在させる

// BAD: Mixing patterns
<input {...register('email')} value={email} onChange={setEmail} />

// GOOD: Choose one pattern
<input {...register('email')} /> // Uncontrolled
// OR
<Controller render={({ field }) => <Input {...field} />} /> // Controlled

❌ useFieldArray でキーにインデックスを使用する

// BAD
{fields.map((field, index) => <div key={index}>{/* ... */}</div>)}

// GOOD
{fields.map((field) => <div key={field.id}>{/* ... */}</div>)}

❌ すべてのフィールドに defaultValues を設定し忘れる

// BAD: Missing defaults causes warnings
const form = useForm({
  resolver: zodResolver(schema),
})

// GOOD: Set defaults for all fields
const form = useForm({
  resolver: zodResolver(schema),
  defaultValues: { email: '', password: '', remember: false },
})

既知の問題の防止

このスキルは、文書化されている 12 件の問題を防ぎます。

問題 #1:Zod v4 の型推論エラー

エラー:Zod v4 で型推論が正しく機能しません 出典:GitHub Issue #13109(2025-11-01 にクローズ) 原因:Zod v4 で型の推論方法が変更されました 防止策:正しい型パターンを使用します:type FormData = z.infer<typeof schema> 注:react-hook-form v7.66.x 以降で解決済みです。この問題を避けるには、最新バージョンにアップグレードしてください。

問題 #2:Uncontrolled から Controlled への変更に関する警告

エラー:「制御されていない入力を制御された入力に変更しています」 出典:React のドキュメント 原因:defaultValues を設定しないと、undefined から value への遷移が発生します 防止策:すべてのフィールドに必ず defaultValues を設定します

問題 #3:ネストされたオブジェクトのバリデーションエラー

エラー:ネストされたフィールドのエラーが正しく表示されません 出典:React Hook Form でよくある問題 原因:ネストされたエラーへのアクセス方法が正しくありません 防止策:オプショナルチェーンを使用します:errors.address?.street?.message

問題 #4:配列フィールドの再レンダリング

エラー:配列フィールドがあるとフォームが過剰に再レンダリングされます 出典:パフォーマンス上の問題 原因:キーに field.id を使用していません 防止策:useFieldArray の map で key={field.id} を使用します

問題 #5:非同期バリデーションの競合状態

エラー:複数のバリデーションリクエストによって結果が競合します 出典:非同期処理でよくある問題 原因:デバウンスもリクエストのキャンセルも行っていません 防止策:バリデーションをデバウンスし、保留中のリクエストをキャンセルします

Issue #6:サーバーエラーのマッピング

エラー:サーバーの検証エラーがフォームフィールドにマッピングされません 発生元:統合に関する問題 原因:サーバーのエラー形式が React Hook Form の形式と一致していません 予防策:setError() を使用してサーバーエラーをフィールドにマッピングします

Issue #7:デフォルト値が適用されない

エラー:フォームフィールドにデフォルト値が表示されません 発生元:よくある間違い 原因:フォームの初期化後に defaultValues が設定されています 予防策:useState ではなく、useForm のオプションで defaultValues を設定します

Issue #8:Controller のフィールドが更新されない

エラー:値が変更されてもカスタムコンポーネントが更新されません 発生元:Controller でよくある問題 原因:render 関数内で {...field} を展開していません 予防策:カスタムコンポーネントには必ず {...field} を展開します

Issue #9:useFieldArray のキーに関する警告

エラー:リスト内のキーが重複しているという React の警告が表示されます 発生元:React のリスト描画 原因:field.id ではなく配列インデックスをキーとして使用しています 予防策:field.id を使用します:key={field.id}

Issue #10:スキーマの refine におけるエラーパス

エラー:カスタム検証エラーが誤ったフィールドに表示されます 発生元:Zod の refine の動作 原因:refine のオプションで path を指定していません 予防策:path オプションを追加します:refine(..., { message: '...', path: ['fieldName'] })

Issue #11:transform と preprocess の混同

エラー:データ変換が想定どおりに動作しません 発生元:Zod API の混同 原因:ユースケースに合わないメソッドを使用しています 予防策:出力の変換には transform、入力の変換には preprocess を使用します

Issue #12:複数の Resolver による競合

エラー:複数の Resolver を使うとフォーム検証が動作しません 発生元:設定エラー 原因:複数の検証ライブラリを使用しようとしています 予防策:単一の Resolver(zodResolver)を使用し、必要に応じてスキーマを結合します


テンプレート

動作するサンプルについては、templates/ ディレクトリを参照してください。

  1. basic-form.tsx - シンプルなログイン/サインアップフォーム
  2. advanced-form.tsx - ネストされたオブジェクト、配列、条件付きフィールド
  3. shadcn-form.tsx - shadcn/ui Form コンポーネントとの統合
  4. server-validation.ts - 同じスキーマを使ったサーバー側検証
  5. async-validation.tsx - デバウンスを使った非同期検証
  6. dynamic-fields.tsx - 項目の追加/削除に使う useFieldArray
  7. multi-step-form.tsx - ステップごとに検証するウィザード
  8. custom-error-display.tsx - カスタムエラーの書式設定
  9. package.json - 依存関係一式

参考資料

詳細なドキュメントについては、references/ ディレクトリを参照してください。

  1. zod-schemas-guide.md - Zod スキーマの包括的なパターン集
  2. rhf-api-reference.md - React Hook Form API の完全なリファレンス
  3. error-handling.md - エラーメッセージ、書式設定、アクセシビリティ
  4. accessibility.md - WCAG 準拠、ARIA 属性
  5. performance-optimization.md - フォームモード、検証戦略
  6. shadcn-integration.md - shadcn/ui の Form コンポーネントと Field コンポーネント
  7. top-errors.md - 解決策付きのよくあるエラー 12 件
  8. links-to-official-docs.md - 整理されたドキュメントへのリンク

公式ドキュメント


ライセンス:MIT 最終確認日:2025-11-20 メンテナー:Jeremy Dawes ([email protected])

コメント