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 Verified: 2026-01-20
Latest Versions: react-hook-form@7.71.1, zod@4.3.5, @hookform/resolvers@5.2.2
状态:可用于生产环境 ✅
最后验证时间:2026-01-20
最新版本:react-hook-form@7.71.1, zod@4.3.5, @hookform/resolvers@5.2.2
Quick Start
快速开始
bash
npm install react-hook-form@7.70.0 zod@4.3.5 @hookform/resolvers@5.2.2Basic Form Pattern:
typescript
const schema = z.object({
email: z.string().email(),
password: z.string().min(8),
})
type FormData = z.infer<typeof schema>
const { register, handleSubmit, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: { email: '', password: '' }, // REQUIRED to prevent uncontrolled warnings
})
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email')} />
{errors.email && <span role="alert">{errors.email.message}</span>}
</form>Server Validation (CRITICAL - never skip):
typescript
// SAME schema on server
const data = schema.parse(await req.json())bash
npm install react-hook-form@7.70.0 zod@4.3.5 @hookform/resolvers@5.2.2基础表单模式:
typescript
const schema = z.object({
email: z.string().email(),
password: z.string().min(8),
})
type FormData = z.infer<typeof schema>
const { register, handleSubmit, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: { email: '', password: '' }, // 必须设置,防止非受控组件警告
})
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email')} />
{errors.email && <span role="alert">{errors.email.message}</span>}
</form>服务端验证(至关重要 - 绝不能跳过):
typescript
// 服务端使用同一套schema
const data = schema.parse(await req.json())Key Patterns
核心模式
useForm Options (validation modes):
- (default) - Best performance
mode: 'onSubmit' - - Good balance
mode: 'onBlur' - - Live feedback, more re-renders
mode: 'onChange' - - Remove field data when unmounted (use for multi-step forms)
shouldUnregister: true
Zod Refinements (cross-field validation):
typescript
z.object({ password: z.string(), confirm: z.string() })
.refine((data) => data.password === data.confirm, {
message: "Passwords don't match",
path: ['confirm'], // CRITICAL: Error appears on this field
})Zod Transforms:
typescript
z.string().transform((val) => val.toLowerCase()) // Data manipulation
z.string().transform(parseInt).refine((v) => v > 0) // Chain with refineZod v4.3.0+ Features:
typescript
// Exact optional (can omit field, but NOT undefined)
z.string().exactOptional()
// Exclusive union (exactly one must match)
z.xor([z.string(), z.number()])
// Import from JSON Schema
z.fromJSONSchema({ type: "object", properties: { name: { type: "string" } } })zodResolver connects Zod to React Hook Form, preserving type safety
useForm 配置项(验证模式):
- (默认)- 性能最佳
mode: 'onSubmit' - - 平衡体验与性能
mode: 'onBlur' - - 实时反馈,重渲染更多
mode: 'onChange' - - 组件卸载时移除字段数据(适用于多步骤表单)
shouldUnregister: true
Zod 自定义验证(跨字段验证):
typescript
z.object({ password: z.string(), confirm: z.string() })
.refine((data) => data.password === data.confirm, {
message: "两次输入的密码不一致",
path: ['confirm'], // 关键:错误提示显示在该字段上
})Zod 数据转换:
typescript
z.string().transform((val) => val.toLowerCase()) // 数据处理
z.string().transform(parseInt).refine((v) => v > 0) // 链式调用验证Zod v4.3.0+ 新特性:
typescript
// 严格可选(可省略字段,但不能为undefined)
z.string().exactOptional()
// 互斥联合(必须且只能匹配其中一个)
z.xor([z.string(), z.number()])
// 从JSON Schema导入
z.fromJSONSchema({ type: "object", properties: { name: { type: "string" } } })zodResolver 用于连接Zod与React Hook Form,保留类型安全性
Registration
字段注册
register (for standard HTML inputs):
typescript
<input {...register('email')} /> // Uncontrolled, best performanceController (for third-party components):
typescript
<Controller
name="category"
control={control}
render={({ field }) => <CustomSelect {...field} />} // MUST spread {...field}
/>When to use Controller: React Select, date pickers, custom components without ref. Otherwise use .
registerregister(适用于标准HTML输入框):
typescript
<input {...register('email')} /> // 非受控组件,性能最佳Controller(适用于第三方组件):
typescript
<Controller
name="category"
control={control}
render={({ field }) => <CustomSelect {...field} />} // 必须展开{...field}
/>何时使用Controller: React Select、日期选择器、无ref的自定义组件。其他情况优先使用。
registerError Handling
错误处理
Display errors:
typescript
{errors.email && <span role="alert">{errors.email.message}</span>}
{errors.address?.street?.message} // Nested errors (use optional chaining)Server errors:
typescript
const onSubmit = async (data) => {
const res = await fetch('/api/submit', { method: 'POST', body: JSON.stringify(data) })
if (!res.ok) {
const { errors: serverErrors } = await res.json()
Object.entries(serverErrors).forEach(([field, msg]) => setError(field, { message: msg }))
}
}显示错误:
typescript
{errors.email && <span role="alert">{errors.email.message}</span>}
{errors.address?.street?.message} // 嵌套字段错误(使用可选链操作符)服务端错误处理:
typescript
const onSubmit = async (data) => {
const res = await fetch('/api/submit', { method: 'POST', body: JSON.stringify(data) })
if (!res.ok) {
const { errors: serverErrors } = await res.json()
Object.entries(serverErrors).forEach(([field, msg]) => setError(field, { message: msg }))
}
}Advanced Patterns
进阶模式
useFieldArray (dynamic lists):
typescript
const { fields, append, remove } = useFieldArray({ control, name: 'contacts' })
{fields.map((field, index) => (
<div key={field.id}> {/* CRITICAL: Use field.id, NOT index */}
<input {...register(`contacts.${index}.name` as const)} />
{errors.contacts?.[index]?.name && <span>{errors.contacts[index].name.message}</span>}
<button onClick={() => remove(index)}>Remove</button>
</div>
))}
<button onClick={() => append({ name: '', email: '' })}>Add</button>Async Validation (debounce):
typescript
const debouncedValidation = useDebouncedCallback(() => trigger('username'), 500)Multi-Step Forms:
typescript
const step1 = z.object({ name: z.string(), email: z.string().email() })
const step2 = z.object({ address: z.string() })
const fullSchema = step1.merge(step2)
const nextStep = async () => {
const isValid = await trigger(['name', 'email']) // Validate specific fields
if (isValid) setStep(2)
}Conditional Validation:
typescript
z.discriminatedUnion('accountType', [
z.object({ accountType: z.literal('personal'), name: z.string() }),
z.object({ accountType: z.literal('business'), companyName: z.string() }),
])Conditional Fields with shouldUnregister:
typescript
const form = useForm({
resolver: zodResolver(schema),
shouldUnregister: false, // Keep values when fields unmount (default)
})
// Or use conditional schema validation:
z.object({
showAddress: z.boolean(),
address: z.string(),
}).refine((data) => {
if (data.showAddress) {
return data.address.length > 0;
}
return true;
}, {
message: "Address is required",
path: ["address"],
})useFieldArray(动态列表):
typescript
const { fields, append, remove } = useFieldArray({ control, name: 'contacts' })
{fields.map((field, index) => (
<div key={field.id}> {/* 关键:使用field.id作为key,而非index */}
<input {...register(`contacts.${index}.name` as const)} />
{errors.contacts?.[index]?.name && <span>{errors.contacts[index].name.message}</span>}
<button onClick={() => remove(index)}>移除</button>
</div>
))}
<button onClick={() => append({ name: '', email: '' })}>添加</button>异步验证(防抖):
typescript
const debouncedValidation = useDebouncedCallback(() => trigger('username'), 500)多步骤表单:
typescript
const step1 = z.object({ name: z.string(), email: z.string().email() })
const step2 = z.object({ address: z.string() })
const fullSchema = step1.merge(step2)
const nextStep = async () => {
const isValid = await trigger(['name', 'email']) // 验证指定字段
if (isValid) setStep(2)
}条件验证:
typescript
z.discriminatedUnion('accountType', [
z.object({ accountType: z.literal('personal'), name: z.string() }),
z.object({ accountType: z.literal('business'), companyName: z.string() }),
])带shouldUnregister的条件字段:
typescript
const form = useForm({
resolver: zodResolver(schema),
shouldUnregister: false, // 组件卸载时保留字段值(默认)
})
// 或使用条件Schema验证:
z.object({
showAddress: z.boolean(),
address: z.string(),
}).refine((data) => {
if (data.showAddress) {
return data.address.length > 0;
}
return true;
}, {
message: "地址为必填项",
path: ["address"],
})shadcn/ui Integration
shadcn/ui 集成
Note: shadcn/ui deprecated the Form component. Use the Field component for new implementations (check latest docs).
Common Import Mistake: IDEs/AI may auto-import from "react-hook-form" instead of from shadcn. Always import:
Formtypescript
// ✅ Correct:
import { useForm } from "react-hook-form";
import { Form, FormField, FormItem } from "@/components/ui/form"; // shadcn
// ❌ Wrong (auto-import mistake):
import { useForm, Form } from "react-hook-form";Legacy Form component:
typescript
<FormField control={form.control} name="username" render={({ field }) => (
<FormItem>
<FormControl><Input {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />注意: shadcn/ui 已弃用Form组件。新实现请使用Field组件(请查阅最新文档)。
常见导入错误: IDE/AI可能会自动从"react-hook-form"导入,而非shadcn的。请始终按以下方式导入:
Formtypescript
// ✅ 正确:
import { useForm } from "react-hook-form";
import { Form, FormField, FormItem } from "@/components/ui/form"; // shadcn
// ❌ 错误(自动导入问题):
import { useForm, Form } from "react-hook-form";旧版Form组件:
typescript
<FormField control={form.control} name="username" render={({ field }) => (
<FormItem>
<FormControl><Input {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />Performance
性能优化
- Use (uncontrolled) over
register(controlled) for standard inputsController - Use not
watch('email')(isolates re-renders to specific fields)watch() - for multi-step forms (clears data on unmount)
shouldUnregister: true
- 对于标准输入框,优先使用(非受控)而非
register(受控)Controller - 使用而非
watch('email')(仅监听指定字段,减少重渲染)watch() - 多步骤表单使用(卸载时清除数据)
shouldUnregister: true
Large Forms (300+ Fields)
大型表单(300+字段)
Warning: Forms with 300+ fields using a resolver (Zod/Yup) AND reading properties can freeze for 10-15 seconds during registration. (Issue #13129)
formStatePerformance Characteristics:
- Clean (no resolver, no formState read): Almost immediate
- With resolver only: Almost immediate
- With formState read only: Almost immediate
- With BOTH resolver + formState read: ~9.5 seconds for 300 fields
Workarounds:
- Avoid destructuring formState - Read properties inline only when needed:
typescript
// ❌ Slow with 300+ fields:
const { isDirty, isValid } = form.formState;
// ✅ Fast:
const handleSubmit = () => {
if (!form.formState.isValid) return; // Read inline only when needed
};- Use mode: "onSubmit" - Don't validate on every change:
typescript
const form = useForm({
resolver: zodResolver(largeSchema),
mode: "onSubmit", // Validate only on submit, not onChange
});- Split into sub-forms - Multiple smaller forms with separate schemas:
typescript
// Instead of one 300-field form, use 5-6 forms with 50-60 fields each
const form1 = useForm({ resolver: zodResolver(schema1) }); // Fields 1-50
const form2 = useForm({ resolver: zodResolver(schema2) }); // Fields 51-100- Lazy render fields - Use tabs/accordion to mount only visible fields:
typescript
// Only mount fields for active tab, reduces initial registration time
{activeTab === 'personal' && <PersonalInfoFields />}
{activeTab === 'address' && <AddressFields />}警告: 使用解析器(Zod/Yup)且读取属性的300+字段表单,在注册阶段可能会冻结10-15秒。(Issue #13129)
formState性能特征:
- 纯净状态(无解析器、无formState读取): 几乎瞬间完成
- 仅使用解析器: 几乎瞬间完成
- 仅读取formState: 几乎瞬间完成
- 同时使用解析器+读取formState: 300个字段约需9.5秒
解决方案:
- 避免解构formState - 仅在需要时内联读取属性:
typescript
// ❌ 300+字段时缓慢:
const { isDirty, isValid } = form.formState;
// ✅ 快速:
const handleSubmit = () => {
if (!form.formState.isValid) return; // 仅在需要时内联读取
};- 使用mode: "onSubmit" - 不要在每次变更时验证:
typescript
const form = useForm({
resolver: zodResolver(largeSchema),
mode: "onSubmit", // 仅在提交时验证,而非变更时
});- 拆分为子表单 - 使用多个独立Schema的小型表单:
typescript
// 不要使用一个300字段的表单,而是拆分为5-6个50-60字段的表单
const form1 = useForm({ resolver: zodResolver(schema1) }); // 字段1-50
const form2 = useForm({ resolver: zodResolver(schema2) }); // 字段51-100- 懒渲染字段 - 使用标签页/折叠面板仅渲染可见字段:
typescript
// 仅渲染当前激活标签页的字段,减少初始注册时间
{activeTab === 'personal' && <PersonalInfoFields />}
{activeTab === 'address' && <AddressFields />}Critical Rules
关键规则
✅ Always set defaultValues (prevents uncontrolled→controlled warnings)
✅ Validate on BOTH client and server (client can be bypassed - security!)
✅ Use as key in useFieldArray (not index)
field.id✅ Spread in Controller render
{...field}✅ Use for type inference
z.infer<typeof schema>❌ Never skip server validation (security vulnerability)
❌ Never mutate values directly (use )
setValue()❌ Never mix controlled + uncontrolled patterns
❌ Never use index as key in useFieldArray
✅ 始终设置defaultValues(防止非受控→受控组件警告)
✅ 同时在客户端和服务端验证(客户端验证可被绕过 - 安全问题!)
✅ 在useFieldArray中使用作为key(而非index)
field.id✅ 在Controller的render函数中展开
{...field}✅ **使用**实现类型推断
z.infer<typeof schema>❌ 绝不能跳过服务端验证(安全漏洞)
❌ 绝不能直接修改值(使用)
setValue()❌ 绝不能混合受控+非受控模式
❌ 绝不能在useFieldArray中使用index作为key
Known Issues (20 Prevented)
已知问题(已规避20项)
-
Uncontrolled→Controlled Warning - Always setfor all fields
defaultValues -
Nested Object Errors - Use optional chaining:
errors.address?.street?.message -
Array Field Re-renders - Usein useFieldArray (not index)
key={field.id} -
Async Validation Race Conditions - Debounce validation, cancel pending requests
-
Server Error Mapping - Useto map server errors to fields
setError() -
Default Values Not Applied - Setin useForm options (not useState)
defaultValues -
Controller Field Not Updating - Always spreadin render function
{...field} -
useFieldArray Key Warnings - Useas key (not index)
field.id -
Schema Refinement Error Paths - Specifyin refinement:
pathrefine(..., { path: ['fieldName'] }) -
Transform vs Preprocess - Usefor output,
transformfor inputpreprocess -
Multiple Resolver Conflicts - Use single resolver (zodResolver), combine schemas if needed
-
Zod v4 Optional Fields Bug - #13102: Setting optional fields () to empty string
.optional()incorrectly triggers validation errors. Workarounds: Use"",.nullish(), or.or(z.literal(""))z.preprocess((val) => val === "" ? undefined : val, z.email().optional()) -
useFieldArray Primitive Arrays Not Supported - #12570: Design limitation.only works with arrays of objects, not primitives like
useFieldArray. Workaround: Wrap primitives in objects:string[]instead of[{ value: "string" }]["string"] -
useFieldArray SSR ID Mismatch - #12782: Hydration mismatch warnings with SSR (Remix, Next.js). Field IDs generated on server don't match client. Workaround: Use client-only rendering for field arrays or wait for V8 (uses deterministic)
key -
Next.js 16 reset() Validation Bug - #13110: Callingafter Server Actions submission causes validation errors on next submit. Fixed in v7.65.0+. Before fix: Use
form.reset()instead ofsetValue()reset() -
Validation Race Condition - #13156: During resolver validation, intermediate render wherebut
isValidating=falsenot populated yet. Don't derive validity from errors alone. Use:errors!errors.field && !isValidating -
ZodError Thrown in Beta Versions - #12816: Zod v4 beta versions throwdirectly instead of capturing in
ZodError. Fixed in stable Zod v4.1.x+. Avoid beta versionsformState.errors -
Large Form Performance - #13129: 300+ fields with resolver + formState read freezes for 10-15 seconds. See Performance section for 4 workarounds
-
shadcn Form Import Confusion - IDEs/AI may auto-importfrom "react-hook-form" instead of shadcn. Always import
Formcomponents fromForm@/components/ui/form
-
非受控→受控警告 - 始终为所有字段设置
defaultValues -
嵌套对象错误 - 使用可选链操作符:
errors.address?.street?.message -
数组字段重渲染 - 在useFieldArray中使用(而非index)
key={field.id} -
异步验证竞态条件 - 对验证进行防抖,取消待处理请求
-
服务端错误映射 - 使用将服务端错误映射到对应字段
setError() -
默认值未生效 - 在useForm配置项中设置(而非useState)
defaultValues -
Controller字段未更新 - 始终在render函数中展开
{...field} -
useFieldArray Key警告 - 使用作为key(而非index)
field.id -
Schema自定义验证错误路径 - 在refine中指定:
pathrefine(..., { path: ['fieldName'] }) -
Transform vs Preprocess - 使用处理输出,
transform处理输入preprocess -
多解析器冲突 - 使用单个解析器(zodResolver),如有需要合并Schema
-
Zod v4可选字段Bug - #13102: 将可选字段()设置为空字符串
.optional()会错误触发验证错误。解决方案: 使用""、.nullish()或.or(z.literal(""))z.preprocess((val) => val === "" ? undefined : val, z.email().optional()) -
useFieldArray不支持原始数组 - #12570: 设计限制。仅支持对象数组,不支持
useFieldArray等原始类型数组。解决方案: 将原始类型包装为对象:string[]替代[{ value: "string" }]["string"] -
useFieldArray SSR ID不匹配 - #12782: SSR(Remix、Next.js)下的 hydration 不匹配警告。服务端生成的字段ID与客户端不一致。解决方案: 对字段数组使用客户端仅渲染,或等待V8版本(使用确定性)
key -
Next.js 16 reset()验证Bug - #13110: Server Actions提交后调用会导致下一次提交时触发验证错误。该问题在v7.65.0+中已修复。修复前: 使用
form.reset()替代setValue()reset() -
验证竞态条件 - #13156: 解析器验证期间,会出现但
isValidating=false尚未填充的中间渲染状态。不要仅通过errors判断有效性。应使用:errors!errors.field && !isValidating -
Beta版本中抛出ZodError - #12816: Zod v4 Beta版本会直接抛出,而非捕获到
ZodError中。该问题在稳定版Zod v4.1.x+中已修复。请避免使用Beta版本formState.errors -
大型表单性能问题 - #13129: 300+字段且同时使用解析器+读取formState的表单会冻结10-15秒。请查看性能优化部分的4种解决方案
-
shadcn Form导入混淆 - IDE/AI可能会自动从"react-hook-form"导入,而非shadcn的。请始终从
Form导入Form组件@/components/ui/form
Upcoming Changes in V8 (Beta)
V8版本(Beta)的即将到来的变更
React Hook Form v8 (currently in beta as of v8.0.0-beta.1, released 2026-01-11) introduces breaking changes. RFC Discussion #7433
Breaking Changes:
- useFieldArray: →
id:key
typescript
// V7:
const { fields } = useFieldArray({ control, name: "items" });
fields.map(field => <div key={field.id}>...</div>)
// V8:
const { fields } = useFieldArray({ control, name: "items" });
fields.map(field => <div key={field.key}>...</div>)
// keyName prop removed- Watch component: →
names:name
typescript
// V7:
<Watch names={["email", "password"]} />
// V8:
<Watch name={["email", "password"]} />- watch() callback API removed:
typescript
// V7:
watch((data, { name, type }) => {
console.log(data, name, type);
});
// V8: Use useWatch or manual subscription
const data = useWatch({ control });
useEffect(() => {
console.log(data);
}, [data]);- setValue() no longer updates useFieldArray:
typescript
// V7:
setValue("items", newArray); // Updates field array
// V8: Must use replace() API
const { replace } = useFieldArray({ control, name: "items" });
replace(newArray);V8 Benefits:
- Fixes SSR hydration mismatch (deterministic instead of random
key)id - Improved performance
- Better TypeScript inference
Migration Timeline: V8 is in beta. Stable release date TBD. Monitor releases for stable version.
React Hook Form v8(截至2026-01-11为Beta版,版本v8.0.0-beta.1)引入了破坏性变更。RFC讨论 #7433
破坏性变更:
- useFieldArray: →
id:key
typescript
// V7:
const { fields } = useFieldArray({ control, name: "items" });
fields.map(field => <div key={field.id}>...</div>)
// V8:
const { fields } = useFieldArray({ control, name: "items" });
fields.map(field => <div key={field.key}>...</div>)
// 移除了keyName属性- Watch组件: →
names:name
typescript
// V7:
<Watch names={["email", "password"]} />
// V8:
<Watch name={["email", "password"]} />- watch()回调API被移除:
typescript
// V7:
watch((data, { name, type }) => {
console.log(data, name, type);
});
// V8: 使用useWatch或手动订阅
const data = useWatch({ control });
useEffect(() => {
console.log(data);
}, [data]);- setValue()不再更新useFieldArray:
typescript
// V7:
setValue("items", newArray); // 更新字段数组
// V8: 必须使用replace() API
const { replace } = useFieldArray({ control, name: "items" });
replace(newArray);V8版本优势:
- 修复了SSR hydration不匹配问题(使用确定性替代随机
key)id - 性能提升
- 更优的TypeScript类型推断
迁移时间线: V8目前为Beta版。稳定版发布日期待定。请关注发布页面获取稳定版信息。
Bundled Resources
配套资源
Templates: basic-form.tsx, advanced-form.tsx, shadcn-form.tsx, server-validation.ts, async-validation.tsx, dynamic-fields.tsx, multi-step-form.tsx, package.json
References: zod-schemas-guide.md, rhf-api-reference.md, error-handling.md, performance-optimization.md, shadcn-integration.md, top-errors.md
License: MIT | Last Verified: 2026-01-20 | Skill Version: 2.1.0 | Changes: Added 8 new known issues (Zod v4 optional fields bug, useFieldArray primitives limitation, SSR hydration mismatch, performance guidance for large forms, Next.js 16 reset() bug, validation race condition, ZodError thrown in beta, shadcn import confusion), added Zod v4.3.0 features (.exactOptional(), .xor(), z.fromJSONSchema()), added conditional field patterns with shouldUnregister, added V8 beta breaking changes section, expanded Zod v4 resolver compatibility notes, updated to react-hook-form@7.71.1
模板: basic-form.tsx, advanced-form.tsx, shadcn-form.tsx, server-validation.ts, async-validation.tsx, dynamic-fields.tsx, multi-step-form.tsx, package.json
参考文档: zod-schemas-guide.md, rhf-api-reference.md, error-handling.md, performance-optimization.md, shadcn-integration.md, top-errors.md
许可证: MIT | 最后验证时间: 2026-01-20 | 技能版本: 2.1.0 | 变更记录: 新增8项已知问题(Zod v4可选字段Bug、useFieldArray原始类型限制、SSR hydration不匹配、大型表单性能指南、Next.js 16 reset() Bug、验证竞态条件、Beta版本抛出ZodError、shadcn导入混淆),新增Zod v4.3.0特性(.exactOptional(), .xor(), z.fromJSONSchema()),新增带shouldUnregister的条件字段模式,新增V8 Beta版破坏性变更部分,扩展Zod v4解析器兼容性说明,更新至react-hook-form@7.71.1",