mcpField
R
react-hook-form-zod

react-hook-form-zod

github.com/jackspace開発1660 回インストール13 週間前に更新
リポジトリ

react-hook-form-zod について

react-hook-formとZodを使ったReactフォーム構築の実践ガイドです。型安全な検証を行う開発者向けに、スキーマ検証、TypeScriptの型推論、カスタム入力、動的・複数ステップのフォーム、エラー処理、性能、アクセシビリティを扱います。

主な機能

  1. 01Zodスキーマをreact-hook-formに接続
  2. 02スキーマからTypeScriptの型を推論
  3. 03カスタム入力と動的フィールドを解説
  4. 04検証タイミングとエラー処理を説明
  5. 05性能とアクセシビリティの実践を掲載

活用シーン

  1. 01スキーマ検証付きの型安全なフォームを作成
  2. 02カスタム入力や動的フィールド配列を検証
  3. 03複数ステップまたは条件付きフォームを実装
  4. 04サーバーエラーとアクセシブルな通知に対応

インストール

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

$npx skills add https://github.com/jackspace/claudeskillz --skill react-hook-form-zod

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

同梱ファイルREADME.mdSKILL.json

詳細

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

ステータス: 本番環境対応済み ✅ 最終更新日: 2025-10-23 依存関係: なし(単独で動作) 最新バージョン: [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>
  )
}

重要:

  • 「非制御から制御への切り替え」に関する警告を防ぐため、必ず 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 のリファインメント(カスタムバリデーション)
// 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 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 を設定し、「非制御から制御への切り替え」警告を防ぐ

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

✅ zodResolver を使用して Zod と統合する

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 発生する理由: Zod v4 で型の推論方法が変更されたため 予防策: 正しい型パターンを使用します。type FormData = z.infer<typeof schema>

問題 #2: 非制御から制御への切り替えに関する警告

エラー: 「コンポーネントが非制御入力を制御入力に変更しています」 出典: React のドキュメント 発生する理由: defaultValues を設定しないと、undefined から value への遷移が発生するため 予防策: すべてのフィールドに defaultValues を必ず設定します

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

エラー: ネストされたフィールドのエラーが正しく表示されない 出典: React Hook Form でよくある問題 発生する理由: ネストされたエラーへのアクセス方法が正しくないため 予防策: オプショナルチェーンを使用します。errors.address?.street?.message

問題 #4: 配列フィールドによる再レンダー

エラー: 配列フィールドを含むフォームが過剰に再レンダーされる 出典: パフォーマンスの問題 発生する理由: キーに field.id を使用していないため 予防策: useFieldArray の map で key={field.id} を使用します

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

エラー: 複数のバリデーションリクエストにより、結果が競合する 出典: よくある非同期処理の問題 発生する理由: デバウンスやリクエストのキャンセルがないため 予防策: バリデーションをデバウンスし、保留中のリクエストをキャンセルします

問題 #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:スキーマの絞り込みでエラーのパスが誤っている

エラー:カスタムバリデーションエラーが誤ったフィールドに表示されます 原因:Zodの絞り込み処理の動作 発生する理由:絞り込みオプションで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-10-23 メンテナー:Jeremy Dawes ([email protected])

コメント