关于 react-hook-form-zod
面向开发者的 React 表单实践指南,介绍如何结合 React Hook Form 与 Zod。内容涵盖模式验证、TypeScript 类型推断、字段模式及进阶表单流程。
主要功能
- 01通过 zodResolver 进行 Zod 模式验证
- 02使用 z.infer 推断类型
- 03标准字段与受控字段模式
- 04动态表单与多步骤表单
- 05错误处理与无障碍建议
适用场景
- 01用共享模式验证表单字段
- 02将自定义输入接入 React Hook Form
- 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.jsonreferences/accessibility.mderror-handling.mdlinks-to-official-docs.mdperformance-optimization.mdrhf-api-reference.mdshadcn-integration.mdtop-errors.mdzod-schemas-guide.mdscripts/check-versions.shtemplates/advanced-form.tsxasync-validation.tsxbasic-form.tsxcustom-error-display.tsxdynamic-fields.tsxmulti-step-form.tsxpackage.jsonserver-validation.tsshadcn-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>
)
}
重要:
- 始终设置
defaultValues,以避免出现“从非受控变为受控”的警告 - 使用
zodResolver(schema)连接 Zod 验证 - 使用
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 Hook 详解
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 的作用:
- 接收你的 Zod 模式
- 将其转换为 React Hook Form 可理解的格式
- 提供在表单提交时运行的验证函数
- 将 Zod 错误映射为 React Hook Form 的错误格式
- 通过 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 组件(推荐)
请查看 shadcn/ui 文档,了解最新的 Field 组件 API,因为这是目前仍在维护的方案。
性能优化
表单模式策略
// 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"
/>
✅ 将 field.id 用作 useFieldArray 的键
{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:从非受控变为受控的警告
错误:“A component is changing an uncontrolled input to be controlled” 来源: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 问题 原因:未在 render 函数中展开 {...field} 预防措施:始终将 {...field} 展开到自定义组件中
问题 #9:useFieldArray 键警告
错误:React 警告列表中存在重复键
来源:React 列表渲染
原因:使用数组索引作为键,而不是 field.id
预防措施:使用 field.id:key={field.id}
问题 #10:Schema 精化错误路径
错误:自定义验证错误显示在错误的字段上
来源:Zod 精化行为
原因:未在精化选项中指定 path
预防措施:添加 path 选项:refine(..., { message: '...', path: ['fieldName'] })
问题 #11:混淆 transform 与 preprocess
错误:数据转换未按预期工作 来源:混淆 Zod API 原因:为当前用例使用了错误的方法 预防措施:使用 transform 转换输出,使用 preprocess 转换输入
问题 #12:多个 Resolver 冲突
错误:使用多个 resolver 时表单验证无法正常工作 来源:配置错误 原因:尝试使用多个验证库 预防措施:使用单个 resolver(zodResolver),必要时合并 schemas
模板
请参阅 templates/ 目录中的可运行示例:
- basic-form.tsx - 简单的登录/注册表单
- advanced-form.tsx - 嵌套对象、数组、条件字段
- shadcn-form.tsx - shadcn/ui Form 组件集成
- server-validation.ts - 使用相同 schema 进行服务端验证
- async-validation.tsx - 带防抖的异步验证
- dynamic-fields.tsx - 使用 useFieldArray 添加/移除项目
- multi-step-form.tsx - 带分步验证的向导
- custom-error-display.tsx - 自定义错误格式
- package.json - 完整依赖项
参考资料
请参阅 references/ 目录中的深入文档:
- zod-schemas-guide.md - 全面的 Zod schema 模式
- rhf-api-reference.md - 完整的 React Hook Form API
- error-handling.md - 错误消息、格式化、无障碍访问
- accessibility.md - WCAG 合规性、ARIA 属性
- performance-optimization.md - 表单模式、验证策略
- shadcn-integration.md - shadcn/ui Form 与 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 ([email protected])