Build type-safe validated forms in React using React Hook Form and Zod schema validation. Single schema works on both client and server for DRY validation with full TypeScript type inference via z.infer.
Use when: building forms with validation, integrating shadcn/ui Form components, implementing multi-step wizards, handling dynamic field arrays with useFieldArray, or fixing uncontrolled to controlled warnings, resolver errors, async validation issues.
Standardmäßig ist der Prompt ausgewählt, der zuerst die Quelle prüft. Sie können zu einem direkten Befehl wechseln oder eine lokale Kopie herunterladen.
Quelldateien prüfen
Lesen Sie SKILL.md und alle von SkillsMP angezeigten Begleitdateien, bevor Sie sich für eine Installation entscheiden.
Mit Codex oder Claude installieren Kopieren Sie diesen Prompt, fügen Sie ihn in Codex, Claude oder einen anderen Assistant ein und lassen Sie die Skill-Seite prüfen und installieren.
Ein direkter Befehl überspringt den Prüf-Prompt. Prüfen Sie die Quelle, bevor Sie ihn ausführen.
Build type-safe validated forms in React using React Hook Form and Zod schema validation. Single schema works on both client and server for DRY validation with full TypeScript type inference via z.infer.
Use when: building forms with validation, integrating shadcn/ui Form components, implementing multi-step wizards, handling dynamic field arrays with useFieldArray, or fixing uncontrolled to controlled warnings, resolver errors, async validation issues.
license
MIT
React Hook Form + Zod Validation
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
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
2. Create Your First Form
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 defaultValues to prevent "uncontrolled to controlled" warnings
Use zodResolver(schema) to connect Zod validation
Type form with z.infer<typeof schema> for full type safety
Validate on both client AND server (never trust client validation alone)
3. Add Server-Side Validation
// 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
Core Concepts
useForm Hook Anatomy
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
false
criteriaMode
Return all errors or first error only
'firstError'
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'
// 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
Using Form Component (Legacy)
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.
Using Field Component (Recommended)
Check shadcn/ui documentation for the latest Field component API as it's the actively maintained approach.
Performance Optimization
Form Mode Strategies
// 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
// 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).
Isolation with Controller
// BAD: Entire form re-renders when any field changes
function BadForm() {
const { watch } = useForm()
const values = watch() // Watches ALL fields
return <div>{JSON.stringify(values)}</div>
}
// GOOD: Only re-render when specific field changes
function GoodForm() {
const { watch } = useForm()
const email = watch('email') // Watches only email field
return <div>{email}</div>
}
shouldUnregister Flag
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)
// 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)
// 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 },
})
Known Issues Prevention
This skill prevents 12 documented issues:
Issue #1: Zod v4 Type Inference Errors
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.
Issue #2: Uncontrolled to Controlled Warning
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
Issue #3: Nested Object Validation Errors
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
Issue #4: Array Field Re-renders
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
Issue #5: Async Validation Race Conditions
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
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
Issue #7: Default Values Not Applied
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
Issue #8: Controller Field Not Updating
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
Issue #9: useFieldArray Key Warnings
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}
Issue #10: Schema Refinement Error Paths
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'] })
Issue #11: Transform vs Preprocess Confusion
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
Issue #12: Multiple Resolver Conflicts
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
Templates
See the templates/ directory for working examples: