react-hook-form-zod by ovachiever/droid-tings
npx skills add https://github.com/ovachiever/droid-tings --skill react-hook-form-zod状态 : 生产就绪 ✅ 最后更新 : 2025-11-20 依赖项 : 无 (独立) 最新版本 : react-hook-form@7.66.1, zod@4.1.12, @hookform/resolvers@5.2.2
npm install react-hook-form@7.66.1 zod@4.1.12 @hookform/resolvers@5.2.2
为何选择这些包 :
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
// 1. 定义验证模式
const loginSchema = z.object({
email: z.string().email('无效的电子邮件地址'),
password: z.string().min(8, '密码必须至少 8 个字符'),
})
// 2. 从模式推断 TypeScript 类型
type LoginFormData = z.infer<typeof loginSchema>
function LoginForm() {
// 3. 使用 zodResolver 初始化表单
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<LoginFormData>({
resolver: zodResolver(loginSchema),
defaultValues: {
email: '',
password: '',
},
})
// 4. 处理表单提交
const onSubmit = async (data: LoginFormData) => {
// 此处数据保证有效
console.log('有效数据:', data)
// 进行 API 调用等操作
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<label htmlFor="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">密码</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 ? '登录中...' : '登录'}
</button>
</form>
)
}
广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
关键点 :
defaultValues 以防止 "uncontrolled to controlled" 警告zodResolver(schema) 连接 Zod 验证z.infer<typeof schema> 为表单提供类型以获得完整的类型安全// server/api/login.ts
import { z } from 'zod'
// 服务器上使用相同的模式
const loginSchema = z.object({
email: z.string().email('无效的电子邮件地址'),
password: z.string().min(8, '密码必须至少 8 个字符'),
})
export async function loginHandler(req: Request) {
try {
// 解析并验证请求体
const data = loginSchema.parse(await req.json())
// 数据是类型安全且经过验证的
// 继续执行身份验证逻辑
return { success: true }
} catch (error) {
if (error instanceof z.ZodError) {
// 将验证错误返回给客户端
return { success: false, errors: error.flatten().fieldErrors }
}
throw error
}
}
为何需要服务器验证 :
const {
register, // 注册输入字段
handleSubmit, // 包装 onSubmit 处理程序
watch, // 监听字段值
formState, // 表单状态 (errors, isValid, isDirty 等)
setValue, // 以编程方式设置字段值
getValues, // 获取当前表单值
reset, // 将表单重置为默认值
trigger, // 手动触发验证
control, // 用于 Controller/useController 的控制对象
} = useForm<FormData>({
resolver: zodResolver(schema), // 验证解析器
mode: 'onSubmit', // 何时验证 (onSubmit, onChange, onBlur, all)
defaultValues: {}, // 初始值 (受控输入必需)
})
useForm 选项 :
| 选项 | 描述 | 默认值 |
|---|---|---|
resolver | 验证解析器 (例如,zodResolver) | undefined |
mode | 何时验证 ('onSubmit', 'onChange', 'onBlur', 'all') | 'onSubmit' |
reValidateMode | 出错后何时重新验证 | 'onChange' |
defaultValues | 初始表单值 | {} |
shouldUnregister | 卸载时是否注销输入 | false |
criteriaMode | 返回所有错误还是仅返回第一个错误 | 'firstError' |
表单验证模式 :
onSubmit - 提交时验证(最佳性能,响应性较差)onChange - 每次更改时验证(实时反馈,更多重新渲染)onBlur - 字段失去焦点时验证(良好平衡)all - 在提交、失去焦点和更改时验证(响应性最强,成本最高)import { z } from 'zod'
// 基本类型
const stringSchema = z.string()
const numberSchema = z.number()
const booleanSchema = z.boolean()
const dateSchema = z.date()
// 带验证
const emailSchema = z.string().email('无效的电子邮件')
const ageSchema = z.number().min(18, '必须年满 18 岁').max(120, '无效年龄')
const usernameSchema = z.string().min(3).max(20).regex(/^[a-zA-Z0-9_]+$/)
// 对象
const userSchema = z.object({
name: z.string(),
email: z.string().email(),
age: z.number().int().positive(),
})
// 数组
const tagsSchema = z.array(z.string())
const usersSchema = z.array(userSchema)
// 可选和可为空
const optionalField = z.string().optional() // string | undefined
const nullableField = z.string().nullable() // string | null
const nullishField = z.string().nullish() // string | null | undefined
// 默认值
const withDefault = z.string().default('默认值')
// 联合类型
const statusSchema = z.union([
z.literal('active'),
z.literal('inactive'),
z.literal('pending'),
])
// 字面量的简写
const statusEnum = z.enum(['active', 'inactive', 'pending'])
// 嵌套对象
const addressSchema = z.object({
street: z.string(),
city: z.string(),
zipCode: z.string().regex(/^\d{5}$/),
})
const profileSchema = z.object({
name: z.string(),
address: addressSchema, // 嵌套对象
})
// 自定义错误消息
const passwordSchema = z.string()
.min(8, { message: '密码必须至少 8 个字符' })
.regex(/[A-Z]/, { message: '密码必须包含大写字母' })
.regex(/[0-9]/, { message: '密码必须包含数字' })
类型推断 :
const userSchema = z.object({
name: z.string(),
age: z.number(),
})
// 自动推断 TypeScript 类型
type User = z.infer<typeof userSchema>
// 结果: { name: string; age: number }
// 简单精炼
const passwordConfirmSchema = z.object({
password: z.string().min(8),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: "密码不匹配",
path: ['confirmPassword'], // 错误将出现在 confirmPassword 字段上
})
// 多重精炼
const signupSchema = z.object({
username: z.string(),
email: z.string().email(),
age: z.number(),
})
.refine((data) => data.username !== data.email.split('@')[0], {
message: '用户名不能是你的电子邮件前缀',
path: ['username'],
})
.refine((data) => data.age >= 18, {
message: '必须年满 18 岁或以上',
path: ['age'],
})
// 异步精炼 (用于 API 检查)
const usernameSchema = z.string().refine(async (username) => {
// 通过 API 检查用户名是否可用
const response = await fetch(`/api/check-username?username=${username}`)
const { available } = await response.json()
return available
}, {
message: '用户名已被占用',
})
// 将字符串转换为数字
const ageSchema = z.string().transform((val) => parseInt(val, 10))
// 转换为大写
const uppercaseSchema = z.string().transform((val) => val.toUpperCase())
// 将日期字符串转换为 Date 对象
const dateSchema = z.string().transform((val) => new Date(val))
// 修剪空白字符
const trimmedSchema = z.string().transform((val) => val.trim())
// 复杂转换
const userInputSchema = z.object({
email: z.string().email().transform((val) => val.toLowerCase()),
tags: z.string().transform((val) => val.split(',').map(tag => tag.trim())),
})
// 链式转换和精炼
const positiveNumberSchema = z.string()
.transform((val) => parseFloat(val))
.refine((val) => !isNaN(val), { message: '必须是数字' })
.refine((val) => val > 0, { message: '必须为正数' })
import { zodResolver } from '@hookform/resolvers/zod'
const form = useForm<FormData>({
resolver: zodResolver(schema),
})
zodResolver 的作用 :
zodResolver 选项 :
import { zodResolver } from '@hookform/resolvers/zod'
// 带选项
const form = useForm({
resolver: zodResolver(schema, {
async: false, // 使用异步验证
raw: false, // 返回原始 Zod 错误
}),
})
function BasicForm() {
const { register, handleSubmit } = useForm<FormData>({
resolver: zodResolver(schema),
})
return (
<form onSubmit={handleSubmit(onSubmit)}>
{/* 将 register 结果展开到 input */}
<input {...register('email')} />
<input {...register('password')} />
{/* 带自定义属性 */}
<input
{...register('username')}
placeholder="输入用户名"
className="input"
/>
</form>
)
}
register() 返回的内容:
{
onChange: (e) => void,
onBlur: (e) => void,
ref: (instance) => void,
name: string,
}
当输入不暴露 ref 时使用 Controller(例如自定义组件、React Select、日期选择器等):
import { Controller } from 'react-hook-form'
function FormWithCustomInput() {
const { control, handleSubmit } = useForm<FormData>({
resolver: zodResolver(schema),
})
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Controller
name="category"
control={control}
render={({ field }) => (
<CustomSelect
{...field} // value, onChange, onBlur, ref
options={categoryOptions}
/>
)}
/>
{/* 更精细的控制 */}
<Controller
name="dateOfBirth"
control={control}
render={({ field, fieldState }) => (
<div>
<DatePicker
selected={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
/>
{fieldState.error && (
<span>{fieldState.error.message}</span>
)}
</div>
)}
/>
</form>
)
}
何时使用 Controller :
何时不使用 Controller :
register 更简单、更快)import { useController } from 'react-hook-form'
// 可重用的自定义输入组件
function CustomInput({ name, control, label }) {
const {
field,
fieldState: { error },
} = useController({
name,
control,
defaultValue: '',
})
return (
<div>
<label>{label}</label>
<input {...field} />
{error && <span>{error.message}</span>}
</div>
)
}
// 用法
function MyForm() {
const { control, handleSubmit } = useForm({
resolver: zodResolver(schema),
})
return (
<form onSubmit={handleSubmit(onSubmit)}>
<CustomInput name="email" control={control} label="电子邮件" />
<CustomInput name="username" control={control} label="用户名" />
</form>
)
}
function FormWithErrors() {
const { register, handleSubmit, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(schema),
})
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<input {...register('email')} aria-invalid={errors.email ? 'true' : 'false'} />
{/* 简单错误显示 */}
{errors.email && <span>{errors.email.message}</span>}
{/* 可访问的错误显示 */}
{errors.email && (
<span role="alert" className="error">
{errors.email.message}
</span>
)}
{/* 带图标的错误 */}
{errors.email && (
<div role="alert" className="error">
<ErrorIcon />
<span>{errors.email.message}</span>
</div>
)}
</div>
</form>
)
}
// errors 对象结构
{
email: {
type: 'invalid_string',
message: '无效的电子邮件地址',
},
password: {
type: 'too_small',
message: '密码必须至少 8 个字符',
},
// 嵌套错误
address: {
street: {
type: 'invalid_type',
message: '期望字符串,收到 undefined',
},
},
}
const schema = z.object({
password: z.string().min(8),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: "密码不匹配",
path: ['confirmPassword'], // 将错误附加到 confirmPassword 字段
})
// 不带 path - 创建根错误
.refine((data) => someCondition, {
message: '表单验证失败',
})
// 访问根错误
const { formState: { errors } } = useForm()
errors.root?.message // 根级错误
function FormWithServerErrors() {
const { register, handleSubmit, setError, formState: { errors } } = useForm({
resolver: zodResolver(schema),
})
const onSubmit = async (data) => {
try {
const response = await fetch('/api/submit', {
method: 'POST',
body: JSON.stringify(data),
})
if (!response.ok) {
const { errors: serverErrors } = await response.json()
// 将服务器错误映射到表单字段
Object.entries(serverErrors).forEach(([field, message]) => {
setError(field, {
type: 'server',
message,
})
})
return
}
// 成功!
} catch (error) {
// 通用错误
setError('root', {
type: 'server',
message: '发生错误。请重试。',
})
}
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
{errors.root && <div role="alert">{errors.root.message}</div>}
{/* ... */}
</form>
)
}
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, '姓名为必填项'),
email: z.string().email('无效的电子邮件'),
})
).min(1, '至少需要一个联系人'),
})
type ContactFormData = z.infer<typeof contactSchema>
function ContactListForm() {
const { register, control, handleSubmit, formState: { errors } } = useForm<ContactFormData>({
resolver: zodResolver(contactSchema),
defaultValues: {
contacts: [{ name: '', email: '' }],
},
})
const { fields, append, remove } = useFieldArray({
control,
name: 'contacts',
})
return (
<form onSubmit={handleSubmit(onSubmit)}>
{fields.map((field, index) => (
<div key={field.id}> {/* 重要:使用 field.id,而不是 index */}
<input
{...register(`contacts.${index}.name` as const)}
placeholder="姓名"
/>
{errors.contacts?.[index]?.name && (
<span>{errors.contacts[index].name.message}</span>
)}
<input
{...register(`contacts.${index}.email` as const)}
placeholder="电子邮件"
/>
{errors.contacts?.[index]?.email && (
<span>{errors.contacts[index].email.message}</span>
)}
<button type="button" onClick={() => remove(index)}>
移除
</button>
</div>
))}
<button
type="button"
onClick={() => append({ name: '', email: '' })}
>
添加联系人
</button>
<button type="submit">提交</button>
</form>
)
}
useFieldArray API :
fields - 具有唯一 ID 的字段项数组append(value) - 在末尾添加新项prepend(value) - 在开头添加新项insert(index, value) - 在索引处插入项remove(index) - 移除索引处的项update(index, value) - 更新索引处的项replace(values) - 替换整个数组import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { useDebouncedCallback } from 'use-debounce' // npm install use-debounce
const usernameSchema = z.string().min(3).refine(async (username) => {
const response = await fetch(`/api/check-username?username=${username}`)
const { available } = await response.json()
return available
}, {
message: '用户名已被占用',
})
function AsyncValidationForm() {
const { register, handleSubmit, trigger, formState: { errors, isValidating } } = useForm({
resolver: zodResolver(z.object({ username: usernameSchema })),
mode: 'onChange', // 每次更改时验证
})
// 防抖验证以避免过多的 API 调用
const debouncedValidation = useDebouncedCallback(() => {
trigger('username')
}, 500) // 用户停止输入后等待 500 毫秒
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input
{...register('username')}
onChange={(e) => {
register('username').onChange(e)
debouncedValidation()
}}
/>
{isValidating && <span>检查可用性...</span>}
{errors.username && <span>{errors.username.message}</span>}
</form>
)
}
import { useState } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
// 步骤模式
const step1Schema = z.object({
name: z.string().min(1, '姓名为必填项'),
email: z.string().email('无效的电子邮件'),
})
const step2Schema = z.object({
address: z.string().min(1, '地址为必填项'),
city: z.string().min(1, '城市为必填项'),
})
const step3Schema = z.object({
cardNumber: z.string().regex(/^\d{16}$/, '无效的卡号'),
cvv: z.string().regex(/^\d{3,4}$/, '无效的 CVV'),
})
// 用于最终验证的组合模式
const fullSchema = step1Schema.merge(step2Schema).merge(step3Schema)
type FormData = z.infer<typeof fullSchema>
function MultiStepForm() {
const [step, setStep] = useState(1)
const { register, handleSubmit, trigger, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(fullSchema),
mode: 'onChange',
})
const nextStep = async () => {
let fieldsToValidate: (keyof FormData)[] = []
if (step === 1) {
fieldsToValidate = ['name', 'email']
} else if (step === 2) {
fieldsToValidate = ['address', 'city']
}
// 验证当前步骤的字段
const isValid = await trigger(fieldsToValidate)
if (isValid) {
setStep(step + 1)
}
}
const prevStep = () => setStep(step - 1)
const onSubmit = (data: FormData) => {
console.log('最终数据:', data)
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
{/* 进度指示器 */}
<div className="progress">
第 {step} 步,共 3 步
</div>
{/* 步骤 1 */}
{step === 1 && (
<div>
<h2>个人信息</h2>
<input {...register('name')} placeholder="姓名" />
{errors.name && <span>{errors.name.message}</span>}
<input {...register('email')} placeholder="电子邮件" />
{errors.email && <span>{errors.email.message}</span>}
</div>
)}
{/* 步骤 2 */}
{step === 2 && (
<div>
<h2>地址</h2>
<input {...register('address')} placeholder="地址" />
{errors.address && <span>{errors.address.message}</span>}
<input {...register('city')} placeholder="城市" />
{errors.city && <span>{errors.city.message}</span>}
</div>
)}
{/* 步骤 3 */}
{step === 3 && (
<div>
<h2>支付</h2>
<input {...register('cardNumber')} placeholder="卡号" />
{errors.cardNumber && <span>{errors.cardNumber.message}</span>}
<input {...register('cvv')} placeholder="CVV" />
{errors.cvv && <span>{errors.cvv.message}</span>}
</div>
)}
{/* 导航 */}
<div>
{step > 1 && (
<button type="button" onClick={prevStep}>
上一步
</button>
)}
{step < 3 ? (
<button type="button" onClick={nextStep}>
下一步
</button>
) : (
<button type="submit">提交</button>
)}
</div>
</form>
)
}
import { z } from 'zod'
// 带条件验证的模式
const formSchema = z.discriminatedUnion('accountType', [
z.object({
accountType: z.literal('personal'),
name: z.string().min(1),
}),
z.object({
accountType: z.literal('business'),
companyName: z.string().min(1),
taxId: z.string().regex(/^\d{9}$/),
}),
])
// 替代方案:使用 refine
const conditionalSchema = z.object({
hasDiscount: z.boolean(),
discountCode: z.string().optional(),
}).refine((data) => {
// 如果 hasDiscount 为 true,则 discountCode 为必填项
if (data.hasDiscount && !data.discountCode) {
return false
}
return true
}, {
message: '启用折扣时,折扣码为必填项',
path: ['discountCode'],
})
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, '用户名必须至少 2 个字符'),
email: z.string().email('无效的电子邮件地址'),
})
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>用户名</FormLabel>
<FormControl>
<Input placeholder="shadcn" {...field} />
</FormControl>
<FormDescription>
这是你的公开显示名称。
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>电子邮件</FormLabel>
<FormControl>
<Input type="email" placeholder="email@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<button type="submit">提交</button>
</form>
</Form>
)
}
注意 : shadcn/ui 声明 "我们不再积极开发 Form 组件。" 他们建议新实现使用 Field 组件。
查看 shadcn/ui 文档以获取最新的 Field 组件 API,因为这是积极维护的方法。
// 最佳性能 - 仅在提交时验证
const form = useForm({
mode: 'onSubmit',
resolver: zodResolver(schema),
})
// 良好平衡 - 失去焦点时验证
const form = useForm({
mode: 'onBlur',
resolver: zodResolver(schema),
})
// 实时反馈 - 每次更改时验证
const form = useForm({
mode: 'onChange',
resolver: zodResolver(schema),
})
// 最大验证 - 所有事件
const form = useForm({
mode: 'all',
resolver: zodResolver(schema),
})
// 非受控 (更好的性能) - 使用 register
<input {...register('email')} />
// 受控 (更多控制) - 使用 Controller
<Controller
name="email"
control={control}
render={({ field }) => <Input {...field} />}
/>
建议 : 对标准输入使用 register,仅在必要时使用 Controller(第三方组件、自定义行为)。
// 不好:任何字段更改时整个表单都会重新渲染
function BadForm() {
const { watch } = useForm()
const values = watch() // 监听所有字段
return <div>{JSON.stringify(values)}</div>
}
// 好:仅在特定字段更改时重新渲染
function GoodForm() {
const { watch } = useForm()
const email = watch('email') // 仅监听 email 字段
return <div>{email}</div>
}
const form = useForm({
resolver: zodResolver(schema),
shouldUnregister: true, // 卸载时移除字段数据
})
何时使用 :
何时不使用 :
function AccessibleForm() {
const { register, handleSubmit, formState: { errors } } = useForm({
resolver: zodResolver(schema),
})
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<label htmlFor="email">电子邮件</label>
<input
id="email"
{...register('email')}
aria-invalid={errors.email ? 'true' : 'false'}
aria-describedby={errors.email ? 'email-error' : undefined}
/>
{errors.email && (
<span id="email-error" role="alert">
{errors.email.message}
</span>
)}
</div>
</form>
)
}
import { useEffect } from 'react'
function FormWithAnnouncements() {
const { formState: { errors, isSubmitted } } = useForm()
// 向屏幕阅读器播报错误
useEffect(() => {
if (isSubmitted && Object.keys(errors).length > 0) {
const errorCount = Object.keys(errors).length
const announcement = `表单提交失败,有 ${errorCount} 个错误${errorCount > 1 ? '' : ''}`
// 为播报创建实时区域
const liveRegion = document.createElement('div')
liveRegion.setAttribute('role', 'alert')
liveRegion.setAttribute('aria-live', 'assertive')
liveRegion.textContent = announcement
document.body.appendChild(liveRegion)
setTimeout(() => {
document.body.removeChild(liveRegion)
}, 1000)
}
}, [errors, isSubmitted])
return (
<form>
{/* ... */}
</form>
)
}
import { useRef, useEffect } from 'react'
function FormWithFocus() {
const { handleSubmit, formState: { errors } } = useForm()
const firstErrorRef = useRef<HTMLInputElement>(null)
// 验证失败时将焦点聚焦到第一个错误字段
useEffect(() => {
if (Object.keys(errors).length > 0) {
firstErrorRef.current?.focus()
}
}, [errors])
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input
{...register('email')}
ref={errors.email ? firstErrorRef : undefined}
/>
</form>
)
}
✅ 设置 defaultValues 以防止 "uncontrolled to controlled" 警告
const form = useForm({
defaultValues: { email: '', password: '' }, // 始终设置默认值
})
✅ 使用 zodResolver 进行 Zod 集成
const form = useForm({
resolver: zodResolver(schema), // Zod 验证必需
})
✅ 使用 z.infer 为表单提供类型
type FormData = z.infer<typeof schema> // 自动类型推断
✅ 在客户端和服务器端都进行验证
// 客户端
const form = useForm({ resolver: zodResolver(schema) })
// 服务器端
const data = schema.parse(await req.json()) // 相同的模式
✅ 使用 formState.errors 显示错误
{errors.email && <span role="alert">{errors.email.message}</span>}
✅ 为无障碍性添加 ARIA 属性
<input
{...register('email')}
aria-invalid={errors.email ? 'true' : 'false'}
aria-describedby="email-error"
/>
✅ 对 useFieldArray 键使用 field.id
{fields.map((field) => <div key={field.id}>{/* ... */}</div>)}
✅ 对异步验证进行防抖
const debouncedValidation = useDebouncedCallback(() => trigger('username'), 500)
❌ 跳过服务器端验证 (安全漏洞
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
npm install react-hook-form@7.66.1 zod@4.1.12 @hookform/resolvers@5.2.2
Why These Packages :
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 :
defaultValues to prevent "uncontrolled to controlled" warningszodResolver(schema) to connect Zod validationz.infer<typeof schema> for full type safety// 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 :
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 |
|---|---|---|
resolver | Validation resolver (e.g., zodResolver) | undefined |
mode | When to validate ('onSubmit', 'onChange', 'onBlur', 'all') | 'onSubmit' |
reValidateMode | When to re-validate after error | 'onChange' |
defaultValues | Initial form values | {} |
shouldUnregister | Unregister inputs when unmounted |
Form Validation Modes :
onSubmit - Validate on submit (best performance, less responsive)onChange - Validate on every change (live feedback, more re-renders)onBlur - Validate when field loses focus (good balance)all - Validate on submit, blur, and change (most responsive, highest cost)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 :
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 }
// 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',
})
// 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' })
import { zodResolver } from '@hookform/resolvers/zod'
const form = useForm<FormData>({
resolver: zodResolver(schema),
})
What zodResolver Does :
zodResolver Options :
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
}),
})
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>
)
}
Whatregister() Returns:
{
onChange: (e) => void,
onBlur: (e) => void,
ref: (instance) => void,
name: string,
}
Use Controller when the input doesn't expose ref (like custom components, React Select, date pickers, etc.):
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 :
When NOT to Use Controller :
register instead - it's simpler and faster)import { useController } from 'react-hook-form'
// Reusable custom input component
function CustomInput({ name, control, label }) {
const {
field,
fieldState: { error },
} = useController({
name,
control,
defaultValue: '',
})
return (
<div>
<label>{label}</label>
<input {...field} />
{error && <span>{error.message}</span>}
</div>
)
}
// Usage
function MyForm() {
const { control, handleSubmit } = useForm({
resolver: zodResolver(schema),
})
return (
<form onSubmit={handleSubmit(onSubmit)}>
<CustomInput name="email" control={control} label="Email" />
<CustomInput name="username" control={control} label="Username" />
</form>
)
}
function FormWithErrors() {
const { register, handleSubmit, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(schema),
})
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<input {...register('email')} aria-invalid={errors.email ? 'true' : 'false'} />
{/* Simple error display */}
{errors.email && <span>{errors.email.message}</span>}
{/* Accessible error display */}
{errors.email && (
<span role="alert" className="error">
{errors.email.message}
</span>
)}
{/* Error with icon */}
{errors.email && (
<div role="alert" className="error">
<ErrorIcon />
<span>{errors.email.message}</span>
</div>
)}
</div>
</form>
)
}
// errors object structure
{
email: {
type: 'invalid_string',
message: 'Invalid email address',
},
password: {
type: 'too_small',
message: 'Password must be at least 8 characters',
},
// Nested errors
address: {
street: {
type: 'invalid_type',
message: 'Expected string, received undefined',
},
},
}
const schema = z.object({
password: z.string().min(8),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ['confirmPassword'], // Attach error to confirmPassword field
})
// Without path - creates root error
.refine((data) => someCondition, {
message: 'Form validation failed',
})
// Access root errors
const { formState: { errors } } = useForm()
errors.root?.message // Root-level error
function FormWithServerErrors() {
const { register, handleSubmit, setError, formState: { errors } } = useForm({
resolver: zodResolver(schema),
})
const onSubmit = async (data) => {
try {
const response = await fetch('/api/submit', {
method: 'POST',
body: JSON.stringify(data),
})
if (!response.ok) {
const { errors: serverErrors } = await response.json()
// Map server errors to form fields
Object.entries(serverErrors).forEach(([field, message]) => {
setError(field, {
type: 'server',
message,
})
})
return
}
// Success!
} catch (error) {
// Generic error
setError('root', {
type: 'server',
message: 'An error occurred. Please try again.',
})
}
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
{errors.root && <div role="alert">{errors.root.message}</div>}
{/* ... */}
</form>
)
}
import { useForm, useFieldArray } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
const contactSchema = z.object({
contacts: z.array(
z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Invalid email'),
})
).min(1, 'At least one contact is required'),
})
type ContactFormData = z.infer<typeof contactSchema>
function ContactListForm() {
const { register, control, handleSubmit, formState: { errors } } = useForm<ContactFormData>({
resolver: zodResolver(contactSchema),
defaultValues: {
contacts: [{ name: '', email: '' }],
},
})
const { fields, append, remove } = useFieldArray({
control,
name: 'contacts',
})
return (
<form onSubmit={handleSubmit(onSubmit)}>
{fields.map((field, index) => (
<div key={field.id}> {/* IMPORTANT: Use field.id, not index */}
<input
{...register(`contacts.${index}.name` as const)}
placeholder="Name"
/>
{errors.contacts?.[index]?.name && (
<span>{errors.contacts[index].name.message}</span>
)}
<input
{...register(`contacts.${index}.email` as const)}
placeholder="Email"
/>
{errors.contacts?.[index]?.email && (
<span>{errors.contacts[index].email.message}</span>
)}
<button type="button" onClick={() => remove(index)}>
Remove
</button>
</div>
))}
<button
type="button"
onClick={() => append({ name: '', email: '' })}
>
Add Contact
</button>
<button type="submit">Submit</button>
</form>
)
}
useFieldArray API :
fields - Array of field items with unique IDsappend(value) - Add new item to endprepend(value) - Add new item to beginninginsert(index, value) - Insert item at indexremove(index) - Remove item at indexupdate(index, value) - Update item at indexreplace(values) - Replace entire arrayimport { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { useDebouncedCallback } from 'use-debounce' // npm install use-debounce
const usernameSchema = z.string().min(3).refine(async (username) => {
const response = await fetch(`/api/check-username?username=${username}`)
const { available } = await response.json()
return available
}, {
message: 'Username is already taken',
})
function AsyncValidationForm() {
const { register, handleSubmit, trigger, formState: { errors, isValidating } } = useForm({
resolver: zodResolver(z.object({ username: usernameSchema })),
mode: 'onChange', // Validate on every change
})
// Debounce validation to avoid too many API calls
const debouncedValidation = useDebouncedCallback(() => {
trigger('username')
}, 500) // Wait 500ms after user stops typing
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input
{...register('username')}
onChange={(e) => {
register('username').onChange(e)
debouncedValidation()
}}
/>
{isValidating && <span>Checking availability...</span>}
{errors.username && <span>{errors.username.message}</span>}
</form>
)
}
import { useState } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
// Step schemas
const step1Schema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Invalid email'),
})
const step2Schema = z.object({
address: z.string().min(1, 'Address is required'),
city: z.string().min(1, 'City is required'),
})
const step3Schema = z.object({
cardNumber: z.string().regex(/^\d{16}$/, 'Invalid card number'),
cvv: z.string().regex(/^\d{3,4}$/, 'Invalid CVV'),
})
// Combined schema for final validation
const fullSchema = step1Schema.merge(step2Schema).merge(step3Schema)
type FormData = z.infer<typeof fullSchema>
function MultiStepForm() {
const [step, setStep] = useState(1)
const { register, handleSubmit, trigger, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(fullSchema),
mode: 'onChange',
})
const nextStep = async () => {
let fieldsToValidate: (keyof FormData)[] = []
if (step === 1) {
fieldsToValidate = ['name', 'email']
} else if (step === 2) {
fieldsToValidate = ['address', 'city']
}
// Validate current step fields
const isValid = await trigger(fieldsToValidate)
if (isValid) {
setStep(step + 1)
}
}
const prevStep = () => setStep(step - 1)
const onSubmit = (data: FormData) => {
console.log('Final data:', data)
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
{/* Progress indicator */}
<div className="progress">
Step {step} of 3
</div>
{/* Step 1 */}
{step === 1 && (
<div>
<h2>Personal Information</h2>
<input {...register('name')} placeholder="Name" />
{errors.name && <span>{errors.name.message}</span>}
<input {...register('email')} placeholder="Email" />
{errors.email && <span>{errors.email.message}</span>}
</div>
)}
{/* Step 2 */}
{step === 2 && (
<div>
<h2>Address</h2>
<input {...register('address')} placeholder="Address" />
{errors.address && <span>{errors.address.message}</span>}
<input {...register('city')} placeholder="City" />
{errors.city && <span>{errors.city.message}</span>}
</div>
)}
{/* Step 3 */}
{step === 3 && (
<div>
<h2>Payment</h2>
<input {...register('cardNumber')} placeholder="Card Number" />
{errors.cardNumber && <span>{errors.cardNumber.message}</span>}
<input {...register('cvv')} placeholder="CVV" />
{errors.cvv && <span>{errors.cvv.message}</span>}
</div>
)}
{/* Navigation */}
<div>
{step > 1 && (
<button type="button" onClick={prevStep}>
Previous
</button>
)}
{step < 3 ? (
<button type="button" onClick={nextStep}>
Next
</button>
) : (
<button type="submit">Submit</button>
)}
</div>
</form>
)
}
import { z } from 'zod'
// Schema with conditional validation
const formSchema = z.discriminatedUnion('accountType', [
z.object({
accountType: z.literal('personal'),
name: z.string().min(1),
}),
z.object({
accountType: z.literal('business'),
companyName: z.string().min(1),
taxId: z.string().regex(/^\d{9}$/),
}),
])
// Alternative: Using refine
const conditionalSchema = z.object({
hasDiscount: z.boolean(),
discountCode: z.string().optional(),
}).refine((data) => {
// If hasDiscount is true, discountCode is required
if (data.hasDiscount && !data.discountCode) {
return false
}
return true
}, {
message: 'Discount code is required when discount is enabled',
path: ['discountCode'],
})
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.
Check shadcn/ui documentation for the latest Field component API as it's the actively maintained approach.
// Best performance - validate only on submit
const form = useForm({
mode: 'onSubmit',
resolver: zodResolver(schema),
})
// Good balance - validate on blur
const form = useForm({
mode: 'onBlur',
resolver: zodResolver(schema),
})
// Live feedback - validate on every change
const form = useForm({
mode: 'onChange',
resolver: zodResolver(schema),
})
// Maximum validation - all events
const form = useForm({
mode: 'all',
resolver: zodResolver(schema),
})
// Uncontrolled (better performance) - use register
<input {...register('email')} />
// Controlled (more control) - use Controller
<Controller
name="email"
control={control}
render={({ field }) => <Input {...field} />}
/>
Recommendation : Use register for standard inputs, Controller only when necessary (third-party components, custom behavior).
// 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>
}
const form = useForm({
resolver: zodResolver(schema),
shouldUnregister: true, // Remove field data when unmounted
})
When to use :
When NOT to use :
function AccessibleForm() {
const { register, handleSubmit, formState: { errors } } = useForm({
resolver: zodResolver(schema),
})
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<label htmlFor="email">Email</label>
<input
id="email"
{...register('email')}
aria-invalid={errors.email ? 'true' : 'false'}
aria-describedby={errors.email ? 'email-error' : undefined}
/>
{errors.email && (
<span id="email-error" role="alert">
{errors.email.message}
</span>
)}
</div>
</form>
)
}
import { useEffect } from 'react'
function FormWithAnnouncements() {
const { formState: { errors, isSubmitted } } = useForm()
// Announce errors to screen readers
useEffect(() => {
if (isSubmitted && Object.keys(errors).length > 0) {
const errorCount = Object.keys(errors).length
const announcement = `Form submission failed with ${errorCount} error${errorCount > 1 ? 's' : ''}`
// Create live region for announcement
const liveRegion = document.createElement('div')
liveRegion.setAttribute('role', 'alert')
liveRegion.setAttribute('aria-live', 'assertive')
liveRegion.textContent = announcement
document.body.appendChild(liveRegion)
setTimeout(() => {
document.body.removeChild(liveRegion)
}, 1000)
}
}, [errors, isSubmitted])
return (
<form>
{/* ... */}
</form>
)
}
import { useRef, useEffect } from 'react'
function FormWithFocus() {
const { handleSubmit, formState: { errors } } = useForm()
const firstErrorRef = useRef<HTMLInputElement>(null)
// Focus first error field on validation failure
useEffect(() => {
if (Object.keys(errors).length > 0) {
firstErrorRef.current?.focus()
}
}, [errors])
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input
{...register('email')}
ref={errors.email ? firstErrorRef : undefined}
/>
</form>
)
}
✅ Set defaultValues to prevent "uncontrolled to controlled" warnings
const form = useForm({
defaultValues: { email: '', password: '' }, // ALWAYS set defaults
})
✅ Use zodResolver for Zod integration
const form = useForm({
resolver: zodResolver(schema), // Required for Zod validation
})
✅ Type forms with z.infer
type FormData = z.infer<typeof schema> // Automatic type inference
✅ Validate on both client AND server
// Client
const form = useForm({ resolver: zodResolver(schema) })
// Server
const data = schema.parse(await req.json()) // SAME schema
✅ Use formState.errors for error display
{errors.email && <span role="alert">{errors.email.message}</span>}
✅ Add ARIA attributes for accessibility
<input
{...register('email')}
aria-invalid={errors.email ? 'true' : 'false'}
aria-describedby="email-error"
/>
✅ Use field.id for useFieldArray keys
{fields.map((field) => <div key={field.id}>{/* ... */}</div>)}
✅ Debounce async validation
const debouncedValidation = useDebouncedCallback(() => trigger('username'), 500)
❌ Skip server-side validation (security vulnerability!)
// 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
// Issue #13109: Zod v4 has type inference changes
// Test your types carefully when upgrading
❌ Forget to spread {...field} in Controller
// BAD
<Controller render={({ field }) => <Input value={field.value} />} />
// GOOD
<Controller render={({ field }) => <Input {...field} />} />
❌ Mutate form values directly
// BAD
const values = getValues()
values.email = 'new@email.com' // Direct mutation
// GOOD
setValue('email', 'new@email.com') // Use setValue
❌ Use inline validation without debouncing
// BAD: Validates on every keystroke
const form = useForm({ mode: 'onChange' })
// GOOD: Debounce async validation
const debouncedTrigger = useDebouncedCallback(() => trigger(), 500)
❌ Mix controlled and uncontrolled inputs
// 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
// BAD
{fields.map((field, index) => <div key={index}>{/* ... */}</div>)}
// GOOD
{fields.map((field) => <div key={field.id}>{/* ... */}</div>)}
❌ Forget defaultValues for all fields
// 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 },
})
This skill prevents 12 documented issues:
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: type FormData = z.infer<typeof schema> Note : Resolved in react-hook-form v7.66.x+. Upgrade to latest version to avoid this issue.
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
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
Error : Form re-renders excessively with array fields Source : Performance issue Why It Happens : Not using field.id as key Prevention : Use key={field.id} in useFieldArray map
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
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
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
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
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}
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'] })
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
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
See the templates/ directory for working examples:
See the references/ directory for deep-dive documentation:
License : MIT Last Verified : 2025-11-20 Maintainer : Jeremy Dawes (jeremy@jezweb.net)
Weekly Installs
271
Repository
GitHub Stars
27
First Seen
Jan 20, 2026
Security Audits
Gen Agent Trust HubPassSocketPassSnykPass
Installed on
opencode243
codex237
gemini-cli236
cursor233
github-copilot231
amp214
TypeScript测试神器:Shoehorn迁移指南,告别as断言,实现类型安全测试
764 周安装
Spring Boot Actuator 生产可观测性指南:健康检查、指标监控与安全配置
381 周安装
Agent Manager Skill:并行管理多个本地CLI代理,支持任务分配与监控
395 周安装
网站克隆工具:使用Firecrawl和Next.js 16将任何网站转换为生产级代码
425 周安装
Spring Data Neo4j 集成指南:在 Spring Boot 中配置、映射与测试图数据库
380 周安装
LangChain4j 工具与函数调用模式:Java AI 代理集成外部 API 与服务的完整指南
382 周安装
响应式图片最佳实践指南:优化LCP与Web性能的核心Web指标
358 周安装
| false |
criteriaMode | Return all errors or first error only | 'firstError' |