react-hook-form-zod
React Hook FormとZodスキーマを組み合わせて、型安全なバリデーション付きフォームをReactで構築します。`z.infer`によるTypeScript型推論を活かしつつ、単一スキーマをクライアント・サーバー両方で共有することでDRYなバリデーションを実現します。フォームのバリデーション実装、shadcn/ui Formコンポーネントの統合、マルチステップウィザードの構築、`useFieldArray`による動的フィールド配列の管理、またはuncontrolled/controlled警告やresolverエラー・非同期バリデーションの問題を解消したい場合に活用してください。
description の原文を見る
| Build type-safe validated forms in React using React Hook Form and Zod schema validation. Single schema works on both client and server for DRY validation with full TypeScript type inference via z.infer. Use when: building forms with validation, integrating shadcn/ui Form components, implementing multi-step wizards, handling dynamic field arrays with useFieldArray, or fixing uncontrolled to controlled warnings, resolver errors, async validation issues.
SKILL.md 本文
React Hook Form + Zod 検証
ステータス: 本番環境対応 ✅ 最終更新: 2025-11-20 依存関係: なし(スタンドアロン) 最新バージョン: react-hook-form@7.66.1, zod@4.1.12, @hookform/resolvers@5.2.2
クイックスタート(10分)
1. パッケージのインストール
npm install react-hook-form@7.66.1 zod@4.1.12 @hookform/resolvers@5.2.2
パッケージを選ぶ理由:
- 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. 検証スキーマを定義
const loginSchema = z.object({
email: z.string().email('Invalid email address'),
password: z.string().min(8, 'Password must be at least 8 characters'),
})
// 2. スキーマから TypeScript 型を推論
type LoginFormData = z.infer<typeof loginSchema>
function LoginForm() {
// 3. zodResolver を使用してフォームを初期化
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<LoginFormData>({
resolver: zodResolver(loginSchema),
defaultValues: {
email: '',
password: '',
},
})
// 4. フォーム送信を処理
const onSubmit = async (data: LoginFormData) => {
// ここで data は必ず有効です
console.log('Valid data:', data)
// API 呼び出しなど
}
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'
// サーバーでも同じスキーマを使用
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 {
// リクエストボディをパースして検証
const data = loginSchema.parse(await req.json())
// data は型安全で検証済み
// 認証ロジックに進む
return { success: true }
} catch (error) {
if (error instanceof z.ZodError) {
// 検証エラーをクライアントに返す
return { success: false, errors: error.flatten().fieldErrors }
}
throw error
}
}
サーバー検証の理由:
- クライアント検証はバイパスできる(要素検査、Postman、curl など)
- サーバー検証はセキュリティレイヤー
- 同じ Zod スキーマ = 単一の情報源
- フロントエンドとバックエンド間の型安全性
コアコンセプト
useForm フックの詳細
const {
register, // 入力フィールドを登録
handleSubmit, // onSubmit ハンドラーをラップ
watch, // フィールド値を監視
formState, // フォーム状態(errors、isValid、isDirty など)
setValue, // フィールド値をプログラムで設定
getValues, // 現在のフォーム値を取得
reset, // フォームをデフォルトに戻す
trigger, // 検証を手動で実行
control, // Controller/useController 用の制御オブジェクト
} = useForm<FormData>({
resolver: zodResolver(schema), // 検証リゾルバー
mode: 'onSubmit', // 検証のタイミング(onSubmit、onChange、onBlur、all)
defaultValues: {}, // 初期値(制御入力に必須)
})
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'
// プリミティブ型
const stringSchema = z.string()
const numberSchema = z.number()
const booleanSchema = z.boolean()
const dateSchema = z.date()
// 検証付き
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_]+$/)
// オブジェクト
const userSchema = z.object({
name: z.string(),
email: z.string().email(),
age: z.number().int().positive(),
})
// 配列
const tagsSchema = z.array(z.string())
const usersSchema = z.array(userSchema)
// オプショナルと Nullable
const optionalField = z.string().optional() // string | undefined
const nullableField = z.string().nullable() // string | null
const nullishField = z.string().nullish() // string | null | undefined
// デフォルト値
const withDefault = z.string().default('default value')
// ユニオン型
const statusSchema = z.union([
z.literal('active'),
z.literal('inactive'),
z.literal('pending'),
])
// リテラルの短縮形
const statusEnum = z.enum(['active', 'inactive', 'pending'])
// ネストされたオブジェクト
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, // ネストされたオブジェクト
})
// カスタムエラーメッセージ
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(),
})
// TypeScript 型を自動推論
type User = z.infer<typeof userSchema>
// 結果: { name: string; age: number }
Zod 洗練(カスタム検証)
// シンプルな洗練
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'], // エラーは confirmPassword フィールドに表示
})
// 複数の洗練
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'],
})
// 非同期洗練(API チェック)
const usernameSchema = z.string().refine(async (username) => {
// API 経由でユーザー名が利用可能かチェック
const response = await fetch(`/api/check-username?username=${username}`)
const { available } = await response.json()
return available
}, {
message: 'Username is already taken',
})
Zod トランスフォーム(データ操作)
// 文字列を数値に変換
const ageSchema = z.string().transform((val) => parseInt(val, 10))
// 大文字に変換
const uppercaseSchema = z.string().transform((val) => val.toUpperCase())
// 日付文字列を Date オブジェクトに変換
const dateSchema = z.string().transform((val) => new Date(val))
// 空白をトリム
const trimmedSchema = z.string().transform((val) => val.trim())
// 複雑な変換
const userInputSchema = z.object({
email: z.string().email().transform((val) => val.toLowerCase()),
tags: z.string().transform((val) => val.split(',').map(tag => tag.trim())),
})
// トランスフォームと洗練をチェーン
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 の役割:
- Zod スキーマを取得
- React Hook Form が理解できる形式に変換
- フォーム送信時に実行される検証関数を提供
- Zod エラーを React Hook Form エラー形式にマッピング
- TypeScript 推論による型安全性を保持
zodResolver オプション:
import { zodResolver } from '@hookform/resolvers/zod'
// オプション付き
const form = useForm({
resolver: zodResolver(schema, {
async: false, // 非同期検証を使用
raw: false, // 生の Zod エラーを返す
}),
})
フォーム登録パターン
パターン 1: シンプルな入力登録
function BasicForm() {
const { register, handleSubmit } = useForm<FormData>({
resolver: zodResolver(schema),
})
return (
<form onSubmit={handleSubmit(onSubmit)}>
{/* register の結果を入力にスプレッド */}
<input {...register('email')} />
<input {...register('password')} />
{/* カスタムプロップ付き */}
<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}
/>
)}
/>
{/* より詳細な制御 */}
<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'
// 再利用可能なカスタム入力コンポーネント
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>
)
}
// 使用方法
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'} />
{/* シンプルなエラー表示 */}
{errors.email && <span>{errors.email.message}</span>}
{/* アクセシビリティ対応エラー表示 */}
{errors.email && (
<span role="alert" className="error">
{errors.email.message}
</span>
)}
{/* アイコン付きエラー */}
{errors.email && (
<div role="alert" className="error">
<ErrorIcon />
<span>{errors.email.message}</span>
</div>
)}
</div>
</form>
)
}
エラーオブジェクト構造
// errors オブジェクト構造
{
email: {
type: 'invalid_string',
message: 'Invalid email address',
},
password: {
type: 'too_small',
message: 'Password must be at least 8 characters',
},
// ネストされたエラー
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'], // confirmPassword フィールドにエラーを添付
})
// path なし - ルートエラーを作成
.refine((data) => someCondition, {
message: 'Form validation failed',
})
// ルートエラーにアクセス
const { formState: { errors } } = useForm()
errors.root?.message // ルートレベルのエラー
サーバーエラー統合
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()
// サーバーエラーをフォームフィールドにマッピング
Object.entries(serverErrors).forEach(([field, message]) => {
setError(field, {
type: 'server',
message,
})
})
return
}
// 成功!
} catch (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}> {/* 重要: インデックスではなく field.id を使用 */}
<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', // すべての変更で検証
})
// API 呼び出しが多すぎるのを避けるため検証をデバウンス
const debouncedValidation = useDebouncedCallback(() => {
trigger('username')
}, 500) // ユーザーが入力を止めてから 500ms 待つ
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'
// ステップごとのスキーマ
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'),
})
// 最終検証用の結合スキーマ
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']
}
// 現在のステップフィールドを検証
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)}>
{/* プログレスインジケーター */}
<div className="progress">
Step {step} of 3
</div>
{/* ステップ 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>
)}
{/* ステップ 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>
)}
{/* ステップ 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>
)}
{/* ナビゲーション */}
<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'
// 条件付き検証のスキーマ
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}$/),
}),
])
// 代替案: refine を使用
const conditionalSchema = z.object({
hasDiscount: z.boolean(),
discountCode: z.string().optional(),
}).refine((data) => {
// hasDiscount が true の場合、discountCode が必須
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@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<button type="submit">Submit</button>
</form>
</Form>
)
}
注意: shadcn/ui は「Form コンポーネントをアクティブに開発していない」と述べています。新しい実装では Field コンポーネントの使用をお勧めします。
Field コンポーネント(推奨)を使用
shadcn/ui の最新 Field コンポーネント API については、shadcn/ui ドキュメントをご確認ください。アクティブにメンテナンスされているアプローチです。
パフォーマンス最適化
フォームモード戦略
// 最高パフォーマンス - 送信時のみ検証
const form = useForm({
mode: 'onSubmit',
resolver: zodResolver(schema),
})
// バランスが良い - ブラー時に検証
const form = useForm({
mode: 'onBlur',
resolver: zodResolver(schema),
})
// ライブフィードバック - すべての変更で検証
const form = useForm({
mode: 'onChange',
resolver: zodResolver(schema),
})
// 最大検証 - すべてのイベント
const form = useForm({
mode: 'all',
resolver: zodResolver(schema),
})
制御入力 vs 未制御入力
// 未制御(パフォーマンスが良い) - register を使用
<input {...register('email')} />
// 制御(より詳細な制御) - Controller を使用
<Controller
name="email"
control={control}
render={({ field }) => <Input {...field} />}
/>
推奨: 標準入力には register を使用、Controller は必要な場合のみ使用(サードパーティコンポーネント、カスタム動作)。
Controller による分離
// 悪い例: フォーム全体がすべてのフィールド変更で再レンダリング
function BadForm() {
const { watch } = useForm()
const values = watch() // すべてのフィールドを監視
return <div>{JSON.stringify(values)}</div>
}
// 良い例: 特定のフィールド変更時のみ再レンダリング
function GoodForm() {
const { watch } = useForm()
const email = watch('email') // メールフィールドのみ監視
return <div>{email}</div>
}
shouldUnregister フラグ
const form = useForm({
resolver: zodResolver(schema),
shouldUnregister: true, // アンマウント時にフィールドデータを削除
})
使用する場面:
- ✅ ステップが異なるフィールドを持つマルチステップフォーム
- ✅ 永続すべきでない条件付きフィールド
- ✅ コンポーネントのアンマウント時にデータをクリア
使用しない場面:
- ❌ 表示切り替え時にフォームデータを保持したい
- ✅ フォームセクション間のナビゲーション(タブ、アコーディオン)
アクセシビリティのベストプラクティス
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()
// スクリーンリーダーにエラーを告知
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' : ''}`
// 告知用のライブリージョンを作成
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)
// 検証エラー時に最初のエラーフィールドにフォーカス
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: '' }, // 常に設定
})
✅ zodResolver を使用 する Zod 統合のため
const form = useForm({
resolver: zodResolver(schema), // Zod 検証に必須
})
✅ z.infer で型指定 する完全な型安全性のため
type FormData = z.infer<typeof schema> // 自動型推論
✅ クライアントとサーバーの両方で検証
// クライアント
const form = useForm({ resolver: zodResolver(schema) })
// サーバー
const data = schema.parse(await req.json()) // 同じスキーマ
✅ 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)
決してしてはいけないこと
❌ サーバー側検証をスキップ (セキュリティ脆弱性!)
// 悪い例: クライアント検証のみ
const form = useForm({ resolver: zodResolver(schema) })
// API エンドポイントに検証がない
// 良い例: クライアントとサーバーで検証
const form = useForm({ resolver: zodResolver(schema) })
// API: サーバーでも schema.parse(data)
❌ Zod v4 の型推論をチェックしない
// 問題 #13109: Zod v4 は型推論の変更がある
// アップグレード時に型を慎重にテスト
❌ Controller で {...field} をスプレッド忘れ
// 悪い例
<Controller render={({ field }) => <Input value={field.value} />} />
// 良い例
<Controller render={({ field }) => <Input {...field} />} />
❌ フォーム値を直接ミューテート
// 悪い例
const values = getValues()
values.email = 'new@email.com' // 直接ミューテーション
// 良い例
setValue('email', 'new@email.com') // setValue を使用
❌ デバウンスなしで非同期検証
// 悪い例: すべてのキーストロークで検証
const form = useForm({ mode: 'onChange' })
// 良い例: 非同期検証をデバウンス
const debouncedTrigger = useDebouncedCallback(() => trigger(), 500)
❌ 制御入力と未制御入力を混在
// 悪い例: パターンの混在
<input {...register('email')} value={email} onChange={setEmail} />
// 良い例: 1 つのパターンを選択
<input {...register('email')} /> // 未制御
// または
<Controller render={({ field }) => <Input {...field} />} /> // 制御
❌ useFieldArray でインデックスをキーに使用
// 悪い例
{fields.map((field, index) => <div key={index}>{/* ... */}</div>)}
// 良い例
{fields.map((field) => <div key={field.id}>{/* ... */}</div>)}
❌ すべてのフィールドで defaultValues を忘れる
// 悪い例: デフォルト値がない
const form = useForm({
resolver: zodResolver(schema),
})
// 良い例: すべてのフィールドにデフォルトを設定
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: 未制御から制御への警告
エラー: 「未制御入力を制御入力に変更しています」 ソース: React ドキュメント 発生理由: defaultValues を設定しないと undefined → value 遷移が起こる 防止方法: 常にすべてのフィールドに defaultValues を設定
問題 #3: ネストされたオブジェクト検証エラー
エラー: ネストされたフィールドのエラーが正しく表示されない
ソース: 一般的な React Hook Form 問題
発生理由: ネストされたエラーへのアクセスが不正
防止方法: オプショナルチェーンを使用: errors.address?.street?.message
問題 #4: 配列フィールドの再レンダリング
エラー: 配列フィールド付きフォームが過度に再レンダリング
ソース: パフォーマンス問題
発生理由: キーとして field.id を使用していない
防止方法: useFieldArray マップで key={field.id} を使用
問題 #5: 非同期検証レース状態
エラー: 複数の検証リクエストが競合結果を生成 ソース: 一般的な非同期パターン問題 発生理由: デバウンスやリクエストキャンセルがない 防止方法: 検証をデバウンスして、保留中のリクエストをキャンセル
問題 #6: サーバーエラーマッピング
エラー: サーバー検証エラーがフォームフィールドにマップされない ソース: 統合問題 発生理由: サーバーエラー形式が React Hook Form 形式に一致しない 防止方法: setError() を使用してサーバーエラーをフィールドにマップ
問題 #7: デフォルト値が適用されない
エラー: フォームフィールドがデフォルト値を表示しない ソース: 一般的なミス 発生理由: フォーム初期化後に defaultValues を設定 防止方法: useForm オプションで defaultValues を設定し、useState ではなく
問題 #8: Controller フィールドが更新されない
エラー: カスタムコンポーネントが値変更で更新されない ソース: 一般的な Controller 問題 発生理由: レンダー関数で {...field} をスプレッドしていない 防止方法: 常にカスタムコンポーネントに {...field} をスプレッド
問題 #9: useFieldArray キー警告
エラー: リスト内で重複キーについて React 警告
ソース: React リスト レンダリング
発生理由: 配列インデックスをキーとして使用し、field.id を使用していない
防止方法: field.id を使用: key={field.id}
問題 #10: スキーマ洗練エラーパス
エラー: カスタム検証エラーが間違ったフィールドに表示
ソース: Zod 洗練動作
発生理由: 洗練オプションで path を指定していない
防止方法: path オプションを追加: refine(..., { message: '...', path: ['fieldName'] })
問題 #11: Transform と Preprocess の混乱
エラー: データ変換が期待どおりに機能しない ソース: Zod API 混乱 発生理由: ユースケースに対する間違ったメソッド使用 防止方法: 出力変換に transform、入力変換に preprocess を使用
問題 #12: 複数リゾルバーの競合
エラー: 複数リゾルバーでフォーム検証が機能しない ソース: 構成エラー 発生理由: 複数の検証ライブラリを使用しようとしている 防止方法: 単一リゾルバーを使用(zodResolver)、必要なら スキーマを結合
テンプレート
templates/ ディレクトリで動作例を参照:
- basic-form.tsx - シンプルなログイン/サインアップフォーム
- advanced-form.tsx - ネストされたオブジェクト、配列、条件付きフィールド
- shadcn-form.tsx - shadcn/ui Form コンポーネント統合
- server-validation.ts - 同じスキーマでサーバー側検証
- async-validation.tsx - デバウンス付き非同期検証
- dynamic-fields.tsx - アイテム追加/削除用 useFieldArray
- multi-step-form.tsx - ステップごとの検証付きウィザード
- custom-error-display.tsx - カスタムエラーフォーマット
- package.json - 完全な依存関係
リファレンス
references/ ディレクトリで詳細なドキュメント参照:
- zod-schemas-guide.md - 包括的な Zod スキーマパターン
- rhf-api-reference.md - 完全な React Hook Form API
- error-handling.md - エラーメッセージ、フォーマット、アクセシビリティ
- accessibility.md - WCAG コンプライアンス、ARIA 属性
- performance-optimization.md - フォームモード、検証戦略
- shadcn-integration.md - shadcn/ui Form vs Field コンポーネント
- top-errors.md - 12 の一般的なエラーと解決策
- links-to-official-docs.md - 整理されたドキュメントリンク
公式ドキュメント
- React Hook Form: https://react-hook-form.com/
- Zod: https://zod.dev/
- @hookform/resolvers: https://github.com/react-hook-form/resolvers
- shadcn/ui Form: https://ui.shadcn.com/docs/components/form
ライセンス: MIT 最終確認: 2025-11-20 メンテナー: Jeremy Dawes (jeremy@jezweb.net)
ライセンス: MIT(寛容ライセンスのため全文を引用しています) · 原本リポジトリ
詳細情報
- 作者
- ovachiever
- ライセンス
- MIT
- 最終更新
- 不明
Source: https://github.com/ovachiever/droid-tings / ライセンス: MIT
関連スキル
agent-browser
AI エージェント向けのブラウザ自動化 CLI です。ウェブサイトとの対話が必要な場合に使用します。ページ遷移、フォーム入力、ボタンクリック、スクリーンショット取得、データ抽出、ウェブアプリのテスト、ブラウザ操作の自動化など、あらゆるブラウザタスクに対応できます。「ウェブサイトを開く」「フォームに記入する」「ボタンをクリックする」「スクリーンショットを取得する」「ページからデータを抽出する」「このウェブアプリをテストする」「サイトにログインする」「ブラウザ操作を自動化する」といった要求や、プログラマティックなウェブ操作が必要なタスクで起動します。
anyskill
AnySkill — あなたのプライベート・スキルクラウド。GitHubを基盤としたリポジトリからエージェントスキルを管理、同期、動的にロードできます。自然言語でクラウドスキルを検索し、オンデマンドでプロンプトを自動ロード、カスタムスキルのアップロードと共有、スキルバンドルの一括インストールが可能です。OpenClaw、Antigravity、Claude Code、Cursorに対応しています。
engram
AIエージェント向けの永続的なメモリシステムです。バグ修正、意思決定、発見、設定変更の後はmem_saveを使用してください。ユーザーが「覚えている」「記憶している」と言及した場合、または以前のセッションと重複する作業を開始する際はmem_searchを使用します。セッション終了前にmem_session_summaryを使用して、コンテキストを保持してください。
skyvern
AI駆動のブラウザ自動化により、任意のウェブサイトを自動化できます。フォーム入力、データ抽出、ファイルダウンロード、ログイン、複数ステップのワークフロー実行など、ユーザーがウェブサイトと連携する必要があるときに使用します。Skyvernは、LLMとコンピュータビジョンを活用して、未知のサイトも自動操作可能です。Python SDK、TypeScript SDK、REST API、MCPサーバー、またはCLIを通じて統合できます。
pinchbench
PinchBenchベンチマークを実行して、OpenClawエージェントの実世界タスクにおけるパフォーマンスを評価できます。モデルの機能テスト、モデル間の比較、ベンチマーク結果のリーダーボード提出、またはOpenClawのセットアップがカレンダー、メール、リサーチ、コーディング、複数ステップのワークフローにどの程度対応しているかを確認する際に使用します。
openui
OpenUIとOpenUI Langを使用してジェネレーティブUIアプリを構築できます。これらはLLM生成インターフェースのためのトークン効率的なオープン標準です。OpenUI、@openuidev、ジェネレーティブUI、LLMからのストリーミングUI、AI向けコンポーネントライブラリ、またはjson-render/A2UIの置き換えについて述べる際に使用します。スキャフォルディング、defineComponent、システムプロンプト、Renderer、およびOpenUI Lang出力のデバッグに対応しています。