react-hook-form-zod by jezweb/claude-skills
npx skills add https://github.com/jezweb/claude-skills --skill react-hook-form-zod状态:生产就绪 ✅ 最后验证:2026-01-20 最新版本:react-hook-form@7.71.1, zod@4.3.5, @hookform/resolvers@5.2.2
npm install react-hook-form@7.70.0 zod@4.3.5 @hookform/resolvers@5.2.2
基础表单模式:
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>
服务端验证(关键 - 切勿跳过):
// 服务端使用相同的 schema
const data = schema.parse(await req.json())
广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
useForm 选项(验证模式):
mode: 'onSubmit'(默认)- 最佳性能mode: 'onBlur' - 良好平衡mode: 'onChange' - 实时反馈,更多重新渲染shouldUnregister: true - 卸载时移除字段数据(用于多步骤表单)Zod 精炼(跨字段验证):
z.object({ password: z.string(), confirm: z.string() })
.refine((data) => data.password === data.confirm, {
message: "Passwords don't match",
path: ['confirm'], // 关键:错误显示在此字段上
})
Zod 转换:
z.string().transform((val) => val.toLowerCase()) // 数据操作
z.string().transform(parseInt).refine((v) => v > 0) // 与 refine 链式调用
Zod v4.3.0+ 功能:
// 精确可选(可以省略字段,但不能是 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,保持类型安全
register(用于标准 HTML 输入):
<input {...register('email')} /> // 非受控,最佳性能
Controller(用于第三方组件):
<Controller
name="category"
control={control}
render={({ field }) => <CustomSelect {...field} />} // 必须展开 {...field}
/>
何时使用 Controller:React Select、日期选择器、没有 ref 的自定义组件。否则使用 register。
显示错误:
{errors.email && <span role="alert">{errors.email.message}</span>}
{errors.address?.street?.message} // 嵌套错误(使用可选链)
服务端错误:
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 }))
}
}
useFieldArray(动态列表):
const { fields, append, remove } = useFieldArray({ control, name: 'contacts' })
{fields.map((field, index) => (
<div key={field.id}> {/* 关键:使用 field.id,而不是 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>
异步验证(防抖):
const debouncedValidation = useDebouncedCallback(() => trigger('username'), 500)
多步骤表单:
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)
}
条件验证:
z.discriminatedUnion('accountType', [
z.object({ accountType: z.literal('personal'), name: z.string() }),
z.object({ accountType: z.literal('business'), companyName: z.string() }),
])
带 shouldUnregister 的条件字段:
const form = useForm({
resolver: zodResolver(schema),
shouldUnregister: false, // 字段卸载时保留值(默认)
})
// 或使用条件模式验证:
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"],
})
注意:shadcn/ui 已弃用 Form 组件。对于新实现,请使用 Field 组件(查看最新文档)。
常见导入错误:IDE/AI 可能会从 "react-hook-form" 自动导入 Form,而不是从 shadcn。请始终导入:
// ✅ 正确:
import { useForm } from "react-hook-form";
import { Form, FormField, FormItem } from "@/components/ui/form"; // shadcn
// ❌ 错误(自动导入错误):
import { useForm, Form } from "react-hook-form";
旧版 Form 组件:
<FormField control={form.control} name="username" render={({ field }) => (
<FormItem>
<FormControl><Input {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
register(非受控)而非 Controller(受控)watch('email') 而非 watch()(将重新渲染隔离到特定字段)shouldUnregister: true(卸载时清除数据)警告:使用解析器(Zod/Yup)且读取 formState 属性的 300+ 字段表单在注册期间可能会冻结 10-15 秒。(Issue #13129)
性能特征:
解决方法:
避免解构 formState - 仅在需要时内联读取属性:
// ❌ 300+ 字段时缓慢:
const { isDirty, isValid } = form.formState;
// ✅ 快速:
const handleSubmit = () => {
if (!form.formState.isValid) return; // 仅在需要时内联读取
};
使用 mode: "onSubmit" - 不要在每个更改时都验证:
const form = useForm({
resolver: zodResolver(largeSchema),
mode: "onSubmit", // 仅在提交时验证,而不是 onChange
});
拆分为子表单 - 多个具有独立模式的小型表单:
// 不要使用一个 300 字段的表单,而是使用 5-6 个表单,每个 50-60 字段
const form1 = useForm({ resolver: zodResolver(schema1) }); // 字段 1-50
const form2 = useForm({ resolver: zodResolver(schema2) }); // 字段 51-100
延迟渲染字段 - 使用选项卡/手风琴仅挂载可见字段:
// 仅挂载活动选项卡的字段,减少初始注册时间
{activeTab === 'personal' && <PersonalInfoFields />}
{activeTab === 'address' && <AddressFields />}
✅ 始终设置 defaultValues(防止非受控→受控警告)
✅ 在客户端和服务端都进行验证(客户端可能被绕过 - 安全!)
✅ 在 useFieldArray 中使用 field.id 作为 key(而不是 index)
✅ 在 Controller render 中展开 {...field}
✅ 使用 z.infer<typeof schema> 进行类型推断
❌ 切勿跳过服务端验证(安全漏洞)
❌ 切勿直接修改值(使用 setValue())
❌ 切勿混合受控 + 非受控模式
❌ 切勿在 useFieldArray 中使用 index 作为 key
Zod v4 类型推断 - #13109:显式使用 z.infer<typeof schema>。在 v7.66.x+ 中已解决。注意:@hookform/resolvers 与 Zod v4 存在 TypeScript 兼容性问题(#813)。解决方法:使用 import { z } from 'zod/v3' 或等待解析器更新。
非受控→受控警告 - 始终为所有字段设置 defaultValues
嵌套对象错误 - 使用可选链:errors.address?.street?.message
数组字段重新渲染 - 在 useFieldArray 中使用 key={field.id}(而不是 index)
异步验证竞态条件 - 防抖验证,取消待处理请求
服务端错误映射 - 使用 setError() 将服务端错误映射到字段
默认值未应用 - 在 useForm 选项中设置 defaultValues(而不是 useState)
Controller 字段未更新 - 始终在 render 函数中展开 {...field}
useFieldArray Key 警告 - 使用 field.id 作为 key(而不是 index)
模式精炼错误路径 - 在精炼中指定 path:refine(..., { path: ['fieldName'] })
Transform 与 Preprocess - 使用 transform 处理输出,preprocess 处理输入
多个解析器冲突 - 使用单个解析器(zodResolver),如果需要则组合模式
Zod v4 可选字段错误 - #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)的水合不匹配警告。服务器生成的字段 ID 与客户端不匹配。解决方法:对字段数组使用仅客户端渲染,或等待 V8(使用确定性 key)
Next.js 16 reset() 验证错误 - #13110:在服务器操作提交后调用 form.reset() 会导致下次提交时出现验证错误。在 v7.65.0+ 中已修复。修复前:使用 setValue() 而不是 reset()
验证竞态条件 - #13156:在解析器验证期间,中间渲染时 isValidating=false 但 errors 尚未填充。不要仅从 errors 推断有效性。使用:!errors.field && !isValidating
Beta 版本中抛出 ZodError - #12816:Zod v4 beta 版本直接抛出 ZodError 而不是捕获到 formState.errors 中。在稳定的 Zod v4.1.x+ 中已修复。避免使用 beta 版本
大型表单性能 - #13129:300+ 字段使用解析器 + 读取 formState 会冻结 10-15 秒。查看性能部分了解 4 种解决方法
shadcn Form 导入混淆 - IDE/AI 可能会从 "react-hook-form" 自动导入 Form 而不是 shadcn。始终从 @/components/ui/form 导入 Form 组件
React Hook Form v8(截至 2026-01-11 发布的 v8.0.0-beta.1 目前处于 beta 阶段)引入了破坏性更改。RFC 讨论 #7433
破坏性更改:
useFieldArray:id → key:
// 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:
// V7:
<Watch names={["email", "password"]} />
// V8:
<Watch name={["email", "password"]} />
watch() 回调 API 已移除:
// V7:
watch((data, { name, type }) => {
console.log(data, name, type);
});
// V8:使用 useWatch 或手动订阅
const data = useWatch({ control });
useEffect(() => {
console.log(data);
}, [data]);
setValue() 不再更新 useFieldArray:
// V7:
setValue("items", newArray); // 更新字段数组
// V8:必须使用 replace() API
const { replace } = useFieldArray({ control, name: "items" });
replace(newArray);
V8 优势:
key 而不是随机 id)迁移时间线:V8 处于 beta 阶段。稳定发布日期待定。请关注发布以获取稳定版本。
模板: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 可选字段错误、useFieldArray 原始类型限制、SSR 水合不匹配、大型表单性能指导、Next.js 16 reset() 错误、验证竞态条件、Beta 版本中抛出 ZodError、shadcn 导入混淆),添加了 Zod v4.3.0 功能(.exactOptional()、.xor()、z.fromJSONSchema()),添加了带 shouldUnregister 的条件字段模式,添加了 V8 beta 破坏性更改部分,扩展了 Zod v4 解析器兼容性说明,更新至 react-hook-form@7.71.1
每周安装量
1.2K
仓库
GitHub 星标数
652
首次出现
Jan 20, 2026
安全审计
安装于
claude-code870
opencode732
gemini-cli709
codex668
github-copilot600
cursor512
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
npm install react-hook-form@7.70.0 zod@4.3.5 @hookform/resolvers@5.2.2
Basic Form Pattern :
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):
// SAME schema on server
const data = schema.parse(await req.json())
useForm Options (validation modes):
mode: 'onSubmit' (default) - Best performancemode: 'onBlur' - Good balancemode: 'onChange' - Live feedback, more re-rendersshouldUnregister: true - Remove field data when unmounted (use for multi-step forms)Zod Refinements (cross-field validation):
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 :
z.string().transform((val) => val.toLowerCase()) // Data manipulation
z.string().transform(parseInt).refine((v) => v > 0) // Chain with refine
Zod v4.3.0+ Features :
// 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
register (for standard HTML inputs):
<input {...register('email')} /> // Uncontrolled, best performance
Controller (for third-party components):
<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 register.
Display errors :
{errors.email && <span role="alert">{errors.email.message}</span>}
{errors.address?.street?.message} // Nested errors (use optional chaining)
Server errors :
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 }))
}
}
useFieldArray (dynamic lists):
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):
const debouncedValidation = useDebouncedCallback(() => trigger('username'), 500)
Multi-Step Forms :
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 :
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 :
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"],
})
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 Form from "react-hook-form" instead of from shadcn. Always import:
// ✅ 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 :
<FormField control={form.control} name="username" render={({ field }) => (
<FormItem>
<FormControl><Input {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
register (uncontrolled) over Controller (controlled) for standard inputswatch('email') not watch() (isolates re-renders to specific fields)shouldUnregister: true for multi-step forms (clears data on unmount)Warning : Forms with 300+ fields using a resolver (Zod/Yup) AND reading formState properties can freeze for 10-15 seconds during registration. (Issue #13129)
Performance Characteristics :
Workarounds :
// ❌ Slow with 300+ fields:
const { isDirty, isValid } = form.formState;
// ✅ Fast:
const handleSubmit = () => {
if (!form.formState.isValid) return; // Read inline only when needed
};
2. Use mode: "onSubmit" - Don't validate on every change:
const form = useForm({
resolver: zodResolver(largeSchema),
mode: "onSubmit", // Validate only on submit, not onChange
});
3. Split into sub-forms - Multiple smaller forms with separate schemas:
// 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
4. Lazy render fields - Use tabs/accordion to mount only visible fields:
// Only mount fields for active tab, reduces initial registration time
{activeTab === 'personal' && <PersonalInfoFields />}
{activeTab === 'address' && <AddressFields />}
✅ Always set defaultValues (prevents uncontrolled→controlled warnings)
✅ Validate on BOTH client and server (client can be bypassed - security!)
✅ Usefield.id as key in useFieldArray (not index)
✅ Spread{...field} in Controller render
✅ Usez.infer<typeof schema> for type inference
❌ Never skip server validation (security vulnerability)
❌ Never mutate values directly (use setValue())
❌ Never mix controlled + uncontrolled patterns
❌ Never use index as key in useFieldArray
Zod v4 Type Inference - #13109: Use z.infer<typeof schema> explicitly. Resolved in v7.66.x+. Note : @hookform/resolvers has TypeScript compatibility issues with Zod v4 (#813). Workaround: Use import { z } from 'zod/v3' or wait for resolver update.
Uncontrolled→Controlled Warning - Always set defaultValues for all fields
Nested Object Errors - Use optional chaining: errors.address?.street?.message
Array Field Re-renders - Use key={field.id} in useFieldArray (not index)
Async Validation Race Conditions - Debounce validation, cancel pending requests
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 :
id → key:// 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
2. Watch component:names → name:
// V7:
<Watch names={["email", "password"]} />
// V8:
<Watch name={["email", "password"]} />
3. watch() callback API removed :
// 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]);
4. setValue() no longer updates useFieldArray :
// V7:
setValue("items", newArray); // Updates field array
// V8: Must use replace() API
const { replace } = useFieldArray({ control, name: "items" });
replace(newArray);
V8 Benefits :
key instead of random id)Migration Timeline : V8 is in beta. Stable release date TBD. Monitor releases for stable version.
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
Docs : https://react-hook-form.com/ | https://zod.dev/ | https://ui.shadcn.com/docs/components/form
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
Weekly Installs
1.2K
Repository
GitHub Stars
652
First Seen
Jan 20, 2026
Security Audits
Gen Agent Trust HubPassSocketPassSnykPass
Installed on
claude-code870
opencode732
gemini-cli709
codex668
github-copilot600
cursor512
React 组合模式指南:Vercel 组件架构最佳实践,提升代码可维护性
102,200 周安装
Server Error Mapping - Use setError() to map server errors to fields
Default Values Not Applied - Set defaultValues in useForm options (not useState)
Controller Field Not Updating - Always spread {...field} in render function
useFieldArray Key Warnings - Use field.id as key (not index)
Schema Refinement Error Paths - Specify path in refinement: refine(..., { path: ['fieldName'] })
Transform vs Preprocess - Use transform for output, preprocess for input
Multiple Resolver Conflicts - Use single resolver (zodResolver), combine schemas if needed
Zod v4 Optional Fields Bug - #13102: Setting optional fields (.optional()) to empty string "" incorrectly triggers validation errors. Workarounds: Use .nullish(), .or(z.literal("")), or z.preprocess((val) => val === "" ? undefined : val, z.email().optional())
useFieldArray Primitive Arrays Not Supported - #12570: Design limitation. useFieldArray only works with arrays of objects, not primitives like string[]. Workaround: Wrap primitives in objects: [{ value: "string" }] instead of ["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: Calling form.reset() after Server Actions submission causes validation errors on next submit. Fixed in v7.65.0+. Before fix: Use setValue() instead of reset()
Validation Race Condition - #13156: During resolver validation, intermediate render where isValidating=false but errors not populated yet. Don't derive validity from errors alone. Use: !errors.field && !isValidating
ZodError Thrown in Beta Versions - #12816: Zod v4 beta versions throw ZodError directly instead of capturing in formState.errors. Fixed in stable Zod v4.1.x+. Avoid beta versions
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-import Form from "react-hook-form" instead of shadcn. Always import Form components from @/components/ui/form