react-hook-form-zod
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseReact Hook Form + Zod Validation
React Hook Form + Zod 验证
Status: Production Ready ✅
Last Updated: 2025-11-20
Dependencies: None (standalone)
Latest Versions: react-hook-form@7.66.1, zod@4.1.12, @hookform/resolvers@5.2.2
状态:已就绪可用于生产环境 ✅
最后更新:2025-11-20
依赖:无(独立使用)
最新版本:react-hook-form@7.66.1, zod@4.1.12, @hookform/resolvers@5.2.2
Quick Start (10 Minutes)
快速入门(10分钟)
1. Install Packages
1. 安装依赖包
bash
npm install react-hook-form@7.66.1 zod@4.1.12 @hookform/resolvers@5.2.2Why These Packages:
- react-hook-form: Performant, flexible form library with minimal re-renders
- zod: TypeScript-first schema validation with type inference
- @hookform/resolvers: Adapter to connect Zod (and other validators) to React Hook Form
bash
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. Create Your First Form
2. 创建你的第一个表单
typescript
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>
)
}CRITICAL:
- Always set to prevent "uncontrolled to controlled" warnings
defaultValues - Use to connect Zod validation
zodResolver(schema) - Type form with for full type safety
z.infer<typeof schema> - Validate on both client AND server (never trust client validation alone)
typescript
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. Add Server-Side Validation
3. 添加服务器端验证
typescript
// 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
}
}Why Server Validation:
- Client validation can be bypassed (inspect element, Postman, curl)
- Server validation is your security layer
- Same Zod schema = single source of truth
- Type safety across frontend and backend
typescript
// 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模式 = 单一事实来源
- 前后端均具备类型安全
Core Concepts
核心概念
useForm Hook Anatomy
useForm Hook 解析
typescript
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 Options:
| Option | Description | Default |
|---|---|---|
| Validation resolver (e.g., zodResolver) | undefined |
| When to validate ('onSubmit', 'onChange', 'onBlur', 'all') | 'onSubmit' |
| When to re-validate after error | 'onChange' |
| Initial form values | {} |
| Unregister inputs when unmounted | false |
| Return all errors or first error only | 'firstError' |
Form Validation Modes:
- - Validate on submit (best performance, less responsive)
onSubmit - - Validate on every change (live feedback, more re-renders)
onChange - - Validate when field loses focus (good balance)
onBlur - - Validate on submit, blur, and change (most responsive, highest cost)
all
typescript
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 配置选项:
| 选项 | 描述 | 默认值 |
|---|---|---|
| 验证解析器(如zodResolver) | undefined |
| 验证触发时机('onSubmit'、'onChange'、'onBlur'、'all') | 'onSubmit' |
| 错误发生后重新验证的时机 | 'onChange' |
| 表单初始值 | {} |
| 组件卸载时是否注销输入字段 | false |
| 返回所有错误还是仅第一个错误 | 'firstError' |
表单验证模式:
- - 提交时验证(性能最佳,响应性稍弱)
onSubmit - - 每次字段变化时验证(实时反馈,重渲染更多)
onChange - - 字段失去焦点时验证(平衡性能与响应性)
onBlur - - 提交、失焦、变化时均验证(响应性最强,性能消耗最高)
all
Zod Schema Definition
Zod 模式定义
typescript
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' })Type Inference:
typescript
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 }typescript
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' })类型推断:
typescript
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 Refinements (Custom Validation)
Zod 自定义验证(Refinements)
typescript
// 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',
})typescript
// 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 Transforms (Data Manipulation)
Zod 数据转换(Transforms)
typescript
// 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' })typescript
// 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 Integration
zodResolver 集成
typescript
import { zodResolver } from '@hookform/resolvers/zod'
const form = useForm<FormData>({
resolver: zodResolver(schema),
})What zodResolver Does:
- Takes your Zod schema
- Converts it to a format React Hook Form understands
- Provides validation function that runs on form submission
- Maps Zod errors to React Hook Form error format
- Preserves type safety with TypeScript inference
zodResolver Options:
typescript
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
}),
})typescript
import { zodResolver } from '@hookform/resolvers/zod'
const form = useForm<FormData>({
resolver: zodResolver(schema),
})zodResolver 的作用:
- 接收你的Zod模式
- 将其转换为React Hook Form可理解的格式
- 提供在表单提交时运行的验证函数
- 将Zod错误映射为React Hook Form的错误格式
- 通过TypeScript推断保留类型安全
zodResolver 配置选项:
typescript
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
}),
})Form Registration Patterns
表单注册模式
Pattern 1: Simple Input Registration
模式1:简单输入字段注册
typescript
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>
)
}What Returns:
register()typescript
{
onChange: (e) => void,
onBlur: (e) => void,
ref: (instance) => void,
name: string,
}typescript
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()typescript
{
onChange: (e) => void,
onBlur: (e) => void,
ref: (instance) => void,
name: string,
}Pattern 2: Controller (for Custom Components)
模式2:Controller(用于自定义组件)
Use when the input doesn't expose (like custom components, React Select, date pickers, etc.):
Controllerreftypescript
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>
)
}When to Use Controller:
- ✅ Third-party UI libraries (React Select, Material-UI, Ant Design, etc.)
- ✅ Custom components that don't expose ref
- ✅ Components that don't use onChange (like checkboxes with custom handlers)
- ✅ Need fine-grained control over field behavior
When NOT to Use Controller:
- ❌ Standard HTML inputs (use instead - it's simpler and faster)
register - ❌ When performance is critical (Controller adds minimal overhead)
当输入组件不暴露时(如自定义组件、React Select、日期选择器等),使用:
refControllertypescript
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会带来极小的性能开销)
Pattern 3: useController (Reusable Controlled Inputs)
模式3:useController(可复用的受控输入组件)
typescript
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>
)
}typescript
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>
)
}Error Handling
错误处理
Displaying Errors
错误展示
typescript
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>
)
}typescript
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>
)
}Error Object Structure
错误对象结构
typescript
// 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',
},
},
}typescript
// 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',
},
},
}Form-Level Validation Errors
表单级验证错误
typescript
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 errortypescript
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 errorServer Errors Integration
服务器错误集成
typescript
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>
)
}typescript
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>
)
}Advanced Patterns
高级模式
Dynamic Form Fields (useFieldArray)
动态表单字段(useFieldArray)
typescript
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:
- - Array of field items with unique IDs
fields - - Add new item to end
append(value) - - Add new item to beginning
prepend(value) - - Insert item at index
insert(index, value) - - Remove item at index
remove(index) - - Update item at index
update(index, value) - - Replace entire array
replace(values)
typescript
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:
- - 带唯一ID的字段项数组
fields - - 向末尾添加新项
append(value) - - 向开头添加新项
prepend(value) - - 在指定索引处插入新项
insert(index, value) - - 删除指定索引处的项
remove(index) - - 更新指定索引处的项
update(index, value) - - 替换整个数组
replace(values)
Async Validation with Debouncing
带防抖的异步验证
typescript
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>
)
}typescript
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>
)
}Multi-Step Form (Wizard)
多步骤表单(向导)
typescript
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>
)
}typescript
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>
)
}Conditional Validation
条件验证
typescript
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'],
})typescript
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 Integration
shadcn/ui 集成
Using Form Component (Legacy)
使用Form组件(旧版)
typescript
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>
)
}Note: shadcn/ui states "We are not actively developing the Form component anymore." They recommend using the Field component for new implementations.
typescript
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组件。
Using Field Component (Recommended)
使用Field组件(推荐)
Check shadcn/ui documentation for the latest Field component API as it's the actively maintained approach.
请查看shadcn/ui文档获取最新的Field组件API,这是当前积极维护的方案。
Performance Optimization
性能优化
Form Mode Strategies
表单模式策略
typescript
// 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),
})typescript
// 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),
})Controlled vs Uncontrolled Inputs
受控与非受控输入框
typescript
// Uncontrolled (better performance) - use register
<input {...register('email')} />
// Controlled (more control) - use Controller
<Controller
name="email"
control={control}
render={({ field }) => <Input {...field} />}
/>Recommendation: Use for standard inputs, only when necessary (third-party components, custom behavior).
registerControllertypescript
// Uncontrolled (better performance) - use register
<input {...register('email')} />
// Controlled (more control) - use Controller
<Controller
name="email"
control={control}
render={({ field }) => <Input {...field} />}
/>建议:标准输入框使用,仅在必要时(第三方组件、自定义行为)使用。
registerControllerIsolation with Controller
使用Controller进行隔离
typescript
// 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>
}typescript
// 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 Flag
shouldUnregister 标志
typescript
const form = useForm({
resolver: zodResolver(schema),
shouldUnregister: true, // Remove field data when unmounted
})When to use:
- ✅ Multi-step forms where steps have different fields
- ✅ Conditional fields that should not persist
- ✅ Want to clear data when component unmounts
When NOT to use:
- ❌ Want to preserve form data when toggling visibility
- ❌ Navigating between form sections (tabs, accordions)
typescript
const form = useForm({
resolver: zodResolver(schema),
shouldUnregister: true, // Remove field data when unmounted
})何时使用:
- ✅ 多步骤表单,各步骤字段不同
- ✅ 条件字段,不需要保留数据
- ✅ 希望在组件卸载时清除数据
何时不使用:
- ❌ 希望在切换可见性时保留表单数据
- ❌ 在表单区域(标签页、折叠面板)之间导航
Accessibility Best Practices
无障碍最佳实践
ARIA Attributes
ARIA 属性
typescript
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>
)
}typescript
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>
)
}Error Announcements
错误通知
typescript
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>
)
}typescript
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>
)
}Focus Management
焦点管理
typescript
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>
)
}typescript
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>
)
}Critical Rules
关键规则
Always Do
务必遵守
✅ Set defaultValues to prevent "uncontrolled to controlled" warnings
typescript
const form = useForm({
defaultValues: { email: '', password: '' }, // ALWAYS set defaults
})✅ Use zodResolver for Zod integration
typescript
const form = useForm({
resolver: zodResolver(schema), // Required for Zod validation
})✅ Type forms with z.infer
typescript
type FormData = z.infer<typeof schema> // Automatic type inference✅ Validate on both client AND server
typescript
// Client
const form = useForm({ resolver: zodResolver(schema) })
// Server
const data = schema.parse(await req.json()) // SAME schema✅ Use formState.errors for error display
typescript
{errors.email && <span role="alert">{errors.email.message}</span>}✅ Add ARIA attributes for accessibility
typescript
<input
{...register('email')}
aria-invalid={errors.email ? 'true' : 'false'}
aria-describedby="email-error"
/>✅ Use field.id for useFieldArray keys
typescript
{fields.map((field) => <div key={field.id}>{/* ... */}</div>)}✅ Debounce async validation
typescript
const debouncedValidation = useDebouncedCallback(() => trigger('username'), 500)✅ 设置defaultValues以避免“非受控转受控”警告
typescript
const form = useForm({
defaultValues: { email: '', password: '' }, // ALWAYS set defaults
})✅ 使用zodResolver进行Zod集成
typescript
const form = useForm({
resolver: zodResolver(schema), // Required for Zod validation
})✅ 使用z.infer为表单添加类型
typescript
type FormData = z.infer<typeof schema> // Automatic type inference✅ 同时在客户端和服务器端验证
typescript
// Client
const form = useForm({ resolver: zodResolver(schema) })
// Server
const data = schema.parse(await req.json()) // SAME schema✅ 使用formState.errors展示错误
typescript
{errors.email && <span role="alert">{errors.email.message}</span>}✅ 添加ARIA属性以支持无障碍
typescript
<input
{...register('email')}
aria-invalid={errors.email ? 'true' : 'false'}
aria-describedby="email-error"
/>✅ 在useFieldArray中使用field.id作为key
typescript
{fields.map((field) => <div key={field.id}>{/* ... */}</div>)}✅ 对异步验证进行防抖
typescript
const debouncedValidation = useDebouncedCallback(() => trigger('username'), 500)Never Do
绝对禁止
❌ Skip server-side validation (security vulnerability!)
typescript
// 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❌ Use Zod v4 without checking type inference
typescript
// Issue #13109: Zod v4 has type inference changes
// Test your types carefully when upgrading❌ Forget to spread {...field} in Controller
typescript
// BAD
<Controller render={({ field }) => <Input value={field.value} />} />
// GOOD
<Controller render={({ field }) => <Input {...field} />} />❌ Mutate form values directly
typescript
// BAD
const values = getValues()
values.email = 'new@email.com' // Direct mutation
// GOOD
setValue('email', 'new@email.com') // Use setValue❌ Use inline validation without debouncing
typescript
// BAD: Validates on every keystroke
const form = useForm({ mode: 'onChange' })
// GOOD: Debounce async validation
const debouncedTrigger = useDebouncedCallback(() => trigger(), 500)❌ Mix controlled and uncontrolled inputs
typescript
// 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❌ Use index as key in useFieldArray
typescript
// BAD
{fields.map((field, index) => <div key={index}>{/* ... */}</div>)}
// GOOD
{fields.map((field) => <div key={field.id}>{/* ... */}</div>)}❌ Forget defaultValues for all fields
typescript
// 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 },
})❌ 跳过服务器端验证(安全漏洞!)
typescript
// 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时不检查类型推断
typescript
// Issue #13109: Zod v4 has type inference changes
// Test your types carefully when upgrading❌ 在Controller中忘记展开{...field}
typescript
// BAD
<Controller render={({ field }) => <Input value={field.value} />} />
// GOOD
<Controller render={({ field }) => <Input {...field} />} />❌ 直接修改表单值
typescript
// BAD
const values = getValues()
values.email = 'new@email.com' // Direct mutation
// GOOD
setValue('email', 'new@email.com') // Use setValue❌ 不对异步验证进行防抖
typescript
// BAD: Validates on every keystroke
const form = useForm({ mode: 'onChange' })
// GOOD: Debounce async validation
const debouncedTrigger = useDebouncedCallback(() => trigger(), 500)❌ 混合使用受控与非受控输入框
typescript
// 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中使用索引作为key
typescript
// BAD
{fields.map((field, index) => <div key={index}>{/* ... */}</div>)}
// GOOD
{fields.map((field) => <div key={field.id}>{/* ... */}</div>)}❌ 不为所有字段设置defaultValues
typescript
// 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 },
})Known Issues Prevention
已知问题预防
This skill prevents 12 documented issues:
本技能可预防12个已记录的问题:
Issue #1: Zod v4 Type Inference Errors
问题#1:Zod v4类型推断错误
Error: Type inference doesn't work correctly with Zod v4
Source: GitHub Issue #13109 (Closed 2025-11-01)
Why It Happens: Zod v4 changed how types are inferred
Prevention: Use correct type patterns:
Note: Resolved in react-hook-form v7.66.x+. Upgrade to latest version to avoid this issue.
type FormData = z.infer<typeof schema>错误:使用Zod v4时类型推断无法正常工作
来源:GitHub Issue #13109(已关闭 2025-11-01)
原因:Zod v4修改了类型推断方式
预防方案:使用正确的类型模式:
注意:在react-hook-form v7.66.x+版本中已解决此问题。升级到最新版本可避免该问题。
type FormData = z.infer<typeof schema>Issue #2: Uncontrolled to Controlled Warning
问题#2:非受控转受控警告
Error: "A component is changing an uncontrolled input to be controlled"
Source: React documentation
Why It Happens: Not setting defaultValues causes undefined -> value transition
Prevention: Always set defaultValues for all fields
错误:“A component is changing an uncontrolled input to be controlled”
来源:React官方文档
原因:未设置defaultValues导致从undefined变为有值的过渡
预防方案:始终为所有字段设置defaultValues
Issue #3: Nested Object Validation Errors
问题#3:嵌套对象验证错误不显示
Error: Errors for nested fields don't display correctly
Source: Common React Hook Form issue
Why It Happens: Accessing nested errors incorrectly
Prevention: Use optional chaining:
errors.address?.street?.message错误:嵌套字段的错误无法正确显示
来源:React Hook Form常见问题
原因:错误地访问嵌套错误
预防方案:使用可选链:
errors.address?.street?.messageIssue #4: Array Field Re-renders
问题#4:数组字段过度重渲染
Error: Form re-renders excessively with array fields
Source: Performance issue
Why It Happens: Not using field.id as key
Prevention: Use in useFieldArray map
key={field.id}错误:包含数组字段的表单过度重渲染
来源:性能问题
原因:未使用field.id作为key
预防方案:在useFieldArray的map中使用
key={field.id}Issue #5: Async Validation Race Conditions
问题#5:异步验证竞态条件
Error: Multiple validation requests cause conflicting results
Source: Common async pattern issue
Why It Happens: No debouncing or request cancellation
Prevention: Debounce validation and cancel pending requests
错误:多个验证请求导致结果冲突
来源:常见异步模式问题
原因:未进行防抖或请求取消
预防方案:对验证进行防抖并取消待处理请求
Issue #6: Server Error Mapping
问题#6:服务器错误映射失败
Error: Server validation errors don't map to form fields
Source: Integration issue
Why It Happens: Server error format doesn't match React Hook Form format
Prevention: Use setError() to map server errors to fields
错误:服务器验证错误无法映射到表单字段
来源:集成问题
原因:服务器错误格式与React Hook Form格式不匹配
预防方案:使用setError()将服务器错误映射到字段
Issue #7: Default Values Not Applied
问题#7:默认值未生效
Error: Form fields don't show default values
Source: Common mistake
Why It Happens: defaultValues set after form initialization
Prevention: Set defaultValues in useForm options, not useState
错误:表单字段未显示默认值
来源:常见错误
原因:在表单初始化后设置defaultValues
预防方案:在useForm选项中设置defaultValues,而非useState
Issue #8: Controller Field Not Updating
问题#8:Controller字段不更新
Error: Custom component doesn't update when value changes
Source: Common Controller issue
Why It Happens: Not spreading {...field} in render function
Prevention: Always spread {...field} to custom component
错误:自定义组件在值变化时不更新
来源:常见Controller问题
原因:在渲染函数中未展开{...field}
预防方案:始终将{...field}展开到自定义组件
Issue #9: useFieldArray Key Warnings
问题#9:useFieldArray Key警告
Error: React warning about duplicate keys in list
Source: React list rendering
Why It Happens: Using array index as key instead of field.id
Prevention: Use field.id:
key={field.id}错误:React警告列表中存在重复key
来源:React列表渲染
原因:使用数组索引而非field.id作为key
预防方案:使用field.id:
key={field.id}Issue #10: Schema Refinement Error Paths
问题#10:Schema Refinement错误路径错误
Error: Custom validation errors appear at wrong field
Source: Zod refinement behavior
Why It Happens: Not specifying path in refinement options
Prevention: Add path option:
refine(..., { message: '...', path: ['fieldName'] })错误:自定义验证错误显示在错误的字段上
来源:Zod refinement行为
原因:未在refinement选项中指定path
预防方案:添加path选项:
refine(..., { message: '...', path: ['fieldName'] })Issue #11: Transform vs Preprocess Confusion
问题#11:Transform与Preprocess混淆
Error: Data transformation doesn't work as expected
Source: Zod API confusion
Why It Happens: Using wrong method for use case
Prevention: Use transform for output transformation, preprocess for input transformation
错误:数据转换未按预期工作
来源:Zod API混淆
原因:使用了错误的方法
预防方案:使用transform进行输出转换,使用preprocess进行输入转换
Issue #12: Multiple Resolver Conflicts
问题#12:多个解析器冲突
Error: Form validation doesn't work with multiple resolvers
Source: Configuration error
Why It Happens: Trying to use multiple validation libraries
Prevention: Use single resolver (zodResolver), combine schemas if needed
错误:使用多个解析器时表单验证失效
来源:配置错误
原因:尝试使用多个验证库
预防方案:使用单个解析器(zodResolver),如需组合验证则合并模式
Templates
模板
See the directory for working examples:
templates/- basic-form.tsx - Simple login/signup form
- advanced-form.tsx - Nested objects, arrays, conditional fields
- shadcn-form.tsx - shadcn/ui Form component integration
- server-validation.ts - Server-side validation with same schema
- async-validation.tsx - Async validation with debouncing
- dynamic-fields.tsx - useFieldArray for adding/removing items
- multi-step-form.tsx - Wizard with per-step validation
- custom-error-display.tsx - Custom error formatting
- package.json - Complete dependencies
请查看目录获取可用示例:
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
参考资料
See the directory for deep-dive documentation:
references/- zod-schemas-guide.md - Comprehensive Zod schema patterns
- rhf-api-reference.md - Complete React Hook Form API
- error-handling.md - Error messages, formatting, accessibility
- accessibility.md - WCAG compliance, ARIA attributes
- performance-optimization.md - Form modes, validation strategies
- shadcn-integration.md - shadcn/ui Form vs Field components
- top-errors.md - 12 common errors with solutions
- links-to-official-docs.md - Organized documentation links
请查看目录获取深度文档:
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与Field组件
- top-errors.md - 12个常见错误及解决方案
- links-to-official-docs.md - 整理好的官方文档链接
Official Documentation
官方文档
- 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
License: MIT
Last Verified: 2025-11-20
Maintainer: Jeremy Dawes (jeremy@jezweb.net)
- 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
License: MIT
Last Verified: 2025-11-20
Maintainer: Jeremy Dawes (jeremy@jezweb.net)