Initial commit

This commit is contained in:
Zhongwei Li
2025-11-30 08:25:27 +08:00
commit c3e8cbf0e4
22 changed files with 7157 additions and 0 deletions

427
templates/advanced-form.tsx Normal file
View File

@@ -0,0 +1,427 @@
/**
* Advanced Form Example - User Profile with Nested Objects and Arrays
*
* Demonstrates:
* - Nested object validation (address)
* - Array field validation (skills)
* - Conditional field validation
* - Complex Zod schemas with refinements
* - Type-safe nested error handling
*/
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
// Define nested schemas
const addressSchema = z.object({
street: z.string().min(1, 'Street is required'),
city: z.string().min(1, 'City is required'),
state: z.string().min(2, 'State must be at least 2 characters'),
zipCode: z.string().regex(/^\d{5}(-\d{4})?$/, 'Invalid ZIP code format'),
country: z.string().min(1, 'Country is required'),
})
// Complex schema with nested objects and arrays
const profileSchema = z.object({
// Basic fields
firstName: z.string().min(2, 'First name must be at least 2 characters'),
lastName: z.string().min(2, 'Last name must be at least 2 characters'),
email: z.string().email('Invalid email address'),
phone: z.string().regex(/^\+?[1-9]\d{1,14}$/, 'Invalid phone number').optional(),
// Nested object
address: addressSchema,
// Array of strings
skills: z.array(z.string().min(1, 'Skill cannot be empty'))
.min(1, 'At least one skill is required')
.max(10, 'Maximum 10 skills allowed'),
// Conditional fields
isStudent: z.boolean(),
school: z.string().optional(),
graduationYear: z.number().int().min(1900).max(2100).optional(),
// Enum
experience: z.enum(['junior', 'mid', 'senior', 'lead'], {
errorMap: () => ({ message: 'Please select experience level' }),
}),
// Number with constraints
yearsOfExperience: z.number()
.int('Must be a whole number')
.min(0, 'Cannot be negative')
.max(50, 'Must be 50 or less'),
// Date
availableFrom: z.date().optional(),
// Boolean
agreedToTerms: z.boolean().refine((val) => val === true, {
message: 'You must agree to the terms and conditions',
}),
})
.refine((data) => {
// Conditional validation: if isStudent is true, school is required
if (data.isStudent && !data.school) {
return false
}
return true
}, {
message: 'School is required for students',
path: ['school'],
})
.refine((data) => {
// Experience level should match years of experience
if (data.experience === 'senior' && data.yearsOfExperience < 5) {
return false
}
return true
}, {
message: 'Senior level requires at least 5 years of experience',
path: ['yearsOfExperience'],
})
type ProfileFormData = z.infer<typeof profileSchema>
export function AdvancedProfileForm() {
const {
register,
handleSubmit,
watch,
formState: { errors, isSubmitting },
setValue,
} = useForm<ProfileFormData>({
resolver: zodResolver(profileSchema),
defaultValues: {
firstName: '',
lastName: '',
email: '',
phone: '',
address: {
street: '',
city: '',
state: '',
zipCode: '',
country: 'USA',
},
skills: [''], // Start with one empty skill
isStudent: false,
school: '',
experience: 'junior',
yearsOfExperience: 0,
agreedToTerms: false,
},
})
// Watch isStudent to conditionally show school field
const isStudent = watch('isStudent')
const onSubmit = async (data: ProfileFormData) => {
console.log('Profile data:', data)
// API call
}
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6 max-w-2xl mx-auto">
<h2 className="text-3xl font-bold">User Profile</h2>
{/* Basic Information */}
<section className="space-y-4">
<h3 className="text-xl font-semibold">Basic Information</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="firstName" className="block text-sm font-medium mb-1">
First Name *
</label>
<input
id="firstName"
{...register('firstName')}
className="w-full px-3 py-2 border rounded-md"
/>
{errors.firstName && (
<span role="alert" className="text-sm text-red-600 mt-1 block">
{errors.firstName.message}
</span>
)}
</div>
<div>
<label htmlFor="lastName" className="block text-sm font-medium mb-1">
Last Name *
</label>
<input
id="lastName"
{...register('lastName')}
className="w-full px-3 py-2 border rounded-md"
/>
{errors.lastName && (
<span role="alert" className="text-sm text-red-600 mt-1 block">
{errors.lastName.message}
</span>
)}
</div>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium mb-1">
Email *
</label>
<input
id="email"
type="email"
{...register('email')}
className="w-full px-3 py-2 border rounded-md"
/>
{errors.email && (
<span role="alert" className="text-sm text-red-600 mt-1 block">
{errors.email.message}
</span>
)}
</div>
<div>
<label htmlFor="phone" className="block text-sm font-medium mb-1">
Phone (Optional)
</label>
<input
id="phone"
type="tel"
{...register('phone')}
placeholder="+1234567890"
className="w-full px-3 py-2 border rounded-md"
/>
{errors.phone && (
<span role="alert" className="text-sm text-red-600 mt-1 block">
{errors.phone.message}
</span>
)}
</div>
</section>
{/* Address (Nested Object) */}
<section className="space-y-4">
<h3 className="text-xl font-semibold">Address</h3>
<div>
<label htmlFor="street" className="block text-sm font-medium mb-1">
Street *
</label>
<input
id="street"
{...register('address.street')}
className="w-full px-3 py-2 border rounded-md"
/>
{errors.address?.street && (
<span role="alert" className="text-sm text-red-600 mt-1 block">
{errors.address.street.message}
</span>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="city" className="block text-sm font-medium mb-1">
City *
</label>
<input
id="city"
{...register('address.city')}
className="w-full px-3 py-2 border rounded-md"
/>
{errors.address?.city && (
<span role="alert" className="text-sm text-red-600 mt-1 block">
{errors.address.city.message}
</span>
)}
</div>
<div>
<label htmlFor="state" className="block text-sm font-medium mb-1">
State *
</label>
<input
id="state"
{...register('address.state')}
className="w-full px-3 py-2 border rounded-md"
/>
{errors.address?.state && (
<span role="alert" className="text-sm text-red-600 mt-1 block">
{errors.address.state.message}
</span>
)}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="zipCode" className="block text-sm font-medium mb-1">
ZIP Code *
</label>
<input
id="zipCode"
{...register('address.zipCode')}
placeholder="12345 or 12345-6789"
className="w-full px-3 py-2 border rounded-md"
/>
{errors.address?.zipCode && (
<span role="alert" className="text-sm text-red-600 mt-1 block">
{errors.address.zipCode.message}
</span>
)}
</div>
<div>
<label htmlFor="country" className="block text-sm font-medium mb-1">
Country *
</label>
<input
id="country"
{...register('address.country')}
className="w-full px-3 py-2 border rounded-md"
/>
{errors.address?.country && (
<span role="alert" className="text-sm text-red-600 mt-1 block">
{errors.address.country.message}
</span>
)}
</div>
</div>
</section>
{/* Skills (Array - simplified for advanced-form, see dynamic-fields.tsx for full array handling) */}
<section className="space-y-4">
<h3 className="text-xl font-semibold">Skills</h3>
<p className="text-sm text-gray-600">
Enter skills separated by commas (handled as string for simplicity in this example)
</p>
<div>
<label htmlFor="skills" className="block text-sm font-medium mb-1">
Skills (comma-separated) *
</label>
<input
id="skills"
{...register('skills.0')} // Simplified - see dynamic-fields.tsx for proper array handling
placeholder="React, TypeScript, Node.js"
className="w-full px-3 py-2 border rounded-md"
/>
{errors.skills && (
<span role="alert" className="text-sm text-red-600 mt-1 block">
{errors.skills.message || errors.skills[0]?.message}
</span>
)}
</div>
</section>
{/* Experience */}
<section className="space-y-4">
<h3 className="text-xl font-semibold">Experience</h3>
<div>
<label htmlFor="experience" className="block text-sm font-medium mb-1">
Experience Level *
</label>
<select
id="experience"
{...register('experience')}
className="w-full px-3 py-2 border rounded-md"
>
<option value="junior">Junior</option>
<option value="mid">Mid-Level</option>
<option value="senior">Senior</option>
<option value="lead">Lead</option>
</select>
{errors.experience && (
<span role="alert" className="text-sm text-red-600 mt-1 block">
{errors.experience.message}
</span>
)}
</div>
<div>
<label htmlFor="yearsOfExperience" className="block text-sm font-medium mb-1">
Years of Experience *
</label>
<input
id="yearsOfExperience"
type="number"
{...register('yearsOfExperience', { valueAsNumber: true })}
className="w-full px-3 py-2 border rounded-md"
/>
{errors.yearsOfExperience && (
<span role="alert" className="text-sm text-red-600 mt-1 block">
{errors.yearsOfExperience.message}
</span>
)}
</div>
</section>
{/* Conditional Fields */}
<section className="space-y-4">
<h3 className="text-xl font-semibold">Education</h3>
<div className="flex items-center">
<input
id="isStudent"
type="checkbox"
{...register('isStudent')}
className="h-4 w-4 rounded"
/>
<label htmlFor="isStudent" className="ml-2 text-sm">
I am currently a student
</label>
</div>
{/* Conditional field - only show if isStudent is true */}
{isStudent && (
<div>
<label htmlFor="school" className="block text-sm font-medium mb-1">
School Name *
</label>
<input
id="school"
{...register('school')}
className="w-full px-3 py-2 border rounded-md"
/>
{errors.school && (
<span role="alert" className="text-sm text-red-600 mt-1 block">
{errors.school.message}
</span>
)}
</div>
)}
</section>
{/* Terms and Conditions */}
<section className="space-y-4">
<div className="flex items-start">
<input
id="agreedToTerms"
type="checkbox"
{...register('agreedToTerms')}
className="h-4 w-4 rounded mt-1"
/>
<label htmlFor="agreedToTerms" className="ml-2 text-sm">
I agree to the terms and conditions *
</label>
</div>
{errors.agreedToTerms && (
<span role="alert" className="text-sm text-red-600 block">
{errors.agreedToTerms.message}
</span>
)}
</section>
{/* Submit Button */}
<button
type="submit"
disabled={isSubmitting}
className="w-full px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-400"
>
{isSubmitting ? 'Saving...' : 'Save Profile'}
</button>
</form>
)
}

View File

@@ -0,0 +1,402 @@
/**
* Async Validation Example
*
* Demonstrates:
* - Async validation with API calls
* - Debouncing to prevent excessive requests
* - Loading states
* - Error handling for async validation
* - Request cancellation
*/
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { useState, useRef, useEffect } from 'react'
/**
* Pattern 1: Async Validation in Zod Schema
*/
const usernameSchema = z.string()
.min(3, 'Username must be at least 3 characters')
.max(20, 'Username must not exceed 20 characters')
.regex(/^[a-zA-Z0-9_]+$/, 'Username can only contain letters, numbers, and underscores')
.refine(async (username) => {
// Check if username is available via API
const response = await fetch(`/api/check-username?username=${encodeURIComponent(username)}`)
const { available } = await response.json()
return available
}, {
message: 'Username is already taken',
})
const signupSchemaWithAsync = z.object({
username: usernameSchema,
email: z.string().email('Invalid email address'),
password: z.string().min(8, 'Password must be at least 8 characters'),
})
type SignupFormData = z.infer<typeof signupSchemaWithAsync>
export function AsyncValidationForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting, isValidating },
} = useForm<SignupFormData>({
resolver: zodResolver(signupSchemaWithAsync),
mode: 'onBlur', // Validate on blur to avoid validating on every keystroke
defaultValues: {
username: '',
email: '',
password: '',
},
})
const onSubmit = async (data: SignupFormData) => {
console.log('Form data:', data)
}
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4 max-w-md mx-auto">
<h2 className="text-2xl font-bold">Sign Up</h2>
<div>
<label htmlFor="username" className="block text-sm font-medium mb-1">
Username
</label>
<input
id="username"
{...register('username')}
className="w-full px-3 py-2 border rounded-md"
/>
{isValidating && (
<span className="text-sm text-blue-600 mt-1 block">
Checking availability...
</span>
)}
{errors.username && (
<span role="alert" className="text-sm text-red-600 mt-1 block">
{errors.username.message}
</span>
)}
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium mb-1">
Email
</label>
<input
id="email"
type="email"
{...register('email')}
className="w-full px-3 py-2 border rounded-md"
/>
{errors.email && (
<span role="alert" className="text-sm text-red-600 mt-1 block">
{errors.email.message}
</span>
)}
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium mb-1">
Password
</label>
<input
id="password"
type="password"
{...register('password')}
className="w-full px-3 py-2 border rounded-md"
/>
{errors.password && (
<span role="alert" className="text-sm text-red-600 mt-1 block">
{errors.password.message}
</span>
)}
</div>
<button
type="submit"
disabled={isSubmitting || isValidating}
className="w-full px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-400"
>
{isSubmitting ? 'Signing up...' : 'Sign Up'}
</button>
</form>
)
}
/**
* Pattern 2: Manual Async Validation with Debouncing and Cancellation
* Better performance - more control over when validation happens
*/
const manualValidationSchema = z.object({
username: z.string()
.min(3, 'Username must be at least 3 characters')
.max(20, 'Username must not exceed 20 characters')
.regex(/^[a-zA-Z0-9_]+$/, 'Username can only contain letters, numbers, and underscores'),
email: z.string().email('Invalid email address'),
})
type ManualValidationData = z.infer<typeof manualValidationSchema>
export function DebouncedAsyncValidationForm() {
const {
register,
handleSubmit,
watch,
setError,
clearErrors,
formState: { errors, isSubmitting },
} = useForm<ManualValidationData>({
resolver: zodResolver(manualValidationSchema),
defaultValues: {
username: '',
email: '',
},
})
const [isCheckingUsername, setIsCheckingUsername] = useState(false)
const [isCheckingEmail, setIsCheckingEmail] = useState(false)
const abortControllerRef = useRef<AbortController | null>(null)
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
const username = watch('username')
const email = watch('email')
// Debounced username validation
useEffect(() => {
// Clear previous timeout
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
// Skip if username is too short (already handled by Zod)
if (!username || username.length < 3) {
setIsCheckingUsername(false)
return
}
// Debounce: wait 500ms after user stops typing
timeoutRef.current = setTimeout(async () => {
// Cancel previous request
if (abortControllerRef.current) {
abortControllerRef.current.abort()
}
// Create new abort controller
abortControllerRef.current = new AbortController()
setIsCheckingUsername(true)
clearErrors('username')
try {
const response = await fetch(
`/api/check-username?username=${encodeURIComponent(username)}`,
{ signal: abortControllerRef.current.signal }
)
const { available } = await response.json()
if (!available) {
setError('username', {
type: 'async',
message: 'Username is already taken',
})
}
} catch (error: any) {
if (error.name !== 'AbortError') {
console.error('Username check error:', error)
}
} finally {
setIsCheckingUsername(false)
}
}, 500) // 500ms debounce
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
}
}, [username, setError, clearErrors])
// Debounced email validation
useEffect(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
// Basic email validation first (handled by Zod)
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
setIsCheckingEmail(false)
return
}
timeoutRef.current = setTimeout(async () => {
setIsCheckingEmail(true)
clearErrors('email')
try {
const response = await fetch(
`/api/check-email?email=${encodeURIComponent(email)}`
)
const { available } = await response.json()
if (!available) {
setError('email', {
type: 'async',
message: 'Email is already registered',
})
}
} catch (error) {
console.error('Email check error:', error)
} finally {
setIsCheckingEmail(false)
}
}, 500)
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
}
}, [email, setError, clearErrors])
const onSubmit = async (data: ManualValidationData) => {
// Final check before submission
if (isCheckingUsername || isCheckingEmail) {
return
}
console.log('Form data:', data)
}
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4 max-w-md mx-auto">
<h2 className="text-2xl font-bold">Create Account</h2>
<div>
<label htmlFor="username" className="block text-sm font-medium mb-1">
Username
</label>
<div className="relative">
<input
id="username"
{...register('username')}
className="w-full px-3 py-2 border rounded-md"
/>
{isCheckingUsername && (
<div className="absolute right-3 top-2.5">
<svg
className="animate-spin h-5 w-5 text-blue-600"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
</div>
)}
</div>
{errors.username && (
<span role="alert" className="text-sm text-red-600 mt-1 block">
{errors.username.message}
</span>
)}
{!errors.username && username.length >= 3 && !isCheckingUsername && (
<span className="text-sm text-green-600 mt-1 block">
Username is available
</span>
)}
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium mb-1">
Email
</label>
<div className="relative">
<input
id="email"
type="email"
{...register('email')}
className="w-full px-3 py-2 border rounded-md"
/>
{isCheckingEmail && (
<div className="absolute right-3 top-2.5">
<svg
className="animate-spin h-5 w-5 text-blue-600"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
</div>
)}
</div>
{errors.email && (
<span role="alert" className="text-sm text-red-600 mt-1 block">
{errors.email.message}
</span>
)}
{!errors.email && email && !isCheckingEmail && (
<span className="text-sm text-green-600 mt-1 block">
Email is available
</span>
)}
</div>
<button
type="submit"
disabled={isSubmitting || isCheckingUsername || isCheckingEmail}
className="w-full px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-400"
>
{isSubmitting ? 'Creating account...' : 'Create Account'}
</button>
</form>
)
}
/**
* Mock API endpoints for testing
*/
export async function checkUsernameAvailability(username: string): Promise<boolean> {
// Simulate API delay
await new Promise(resolve => setTimeout(resolve, 1000))
// Mock: usernames starting with 'test' are taken
return !username.toLowerCase().startsWith('test')
}
export async function checkEmailAvailability(email: string): Promise<boolean> {
await new Promise(resolve => setTimeout(resolve, 1000))
// Mock: emails with 'test' are taken
return !email.toLowerCase().includes('test')
}

285
templates/basic-form.tsx Normal file
View File

@@ -0,0 +1,285 @@
/**
* Basic Form Example - Login/Signup Form
*
* Demonstrates:
* - Simple form with email and password validation
* - useForm hook with zodResolver
* - Error display
* - Type-safe form data with z.infer
* - Accessible error messages
*/
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
// 1. Define Zod validation schema
const loginSchema = z.object({
email: z.string()
.min(1, 'Email is required')
.email('Invalid email address'),
password: z.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
.regex(/[a-z]/, 'Password must contain at least one lowercase letter')
.regex(/[0-9]/, 'Password must contain at least one number'),
rememberMe: z.boolean().optional(),
})
// 2. Infer TypeScript type from schema
type LoginFormData = z.infer<typeof loginSchema>
export function BasicLoginForm() {
// 3. Initialize form with zodResolver
const {
register,
handleSubmit,
formState: { errors, isSubmitting, isValid },
reset,
} = useForm<LoginFormData>({
resolver: zodResolver(loginSchema),
mode: 'onBlur', // Validate on blur for better UX
defaultValues: {
email: '',
password: '',
rememberMe: false,
},
})
// 4. Handle form submission
const onSubmit = async (data: LoginFormData) => {
try {
console.log('Form data:', data)
// Make API call
const response = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!response.ok) {
throw new Error('Login failed')
}
const result = await response.json()
console.log('Login successful:', result)
// Reset form after successful submission
reset()
} catch (error) {
console.error('Login error:', error)
}
}
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4 max-w-md mx-auto">
<h2 className="text-2xl font-bold">Login</h2>
{/* Email Field */}
<div>
<label htmlFor="email" className="block text-sm font-medium mb-1">
Email
</label>
<input
id="email"
type="email"
{...register('email')}
aria-invalid={errors.email ? 'true' : 'false'}
aria-describedby={errors.email ? 'email-error' : undefined}
className={`w-full px-3 py-2 border rounded-md ${
errors.email ? 'border-red-500' : 'border-gray-300'
}`}
placeholder="you@example.com"
/>
{errors.email && (
<span
id="email-error"
role="alert"
className="text-sm text-red-600 mt-1 block"
>
{errors.email.message}
</span>
)}
</div>
{/* Password Field */}
<div>
<label htmlFor="password" className="block text-sm font-medium mb-1">
Password
</label>
<input
id="password"
type="password"
{...register('password')}
aria-invalid={errors.password ? 'true' : 'false'}
aria-describedby={errors.password ? 'password-error' : undefined}
className={`w-full px-3 py-2 border rounded-md ${
errors.password ? 'border-red-500' : 'border-gray-300'
}`}
placeholder="••••••••"
/>
{errors.password && (
<span
id="password-error"
role="alert"
className="text-sm text-red-600 mt-1 block"
>
{errors.password.message}
</span>
)}
</div>
{/* Remember Me Checkbox */}
<div className="flex items-center">
<input
id="rememberMe"
type="checkbox"
{...register('rememberMe')}
className="h-4 w-4 rounded"
/>
<label htmlFor="rememberMe" className="ml-2 text-sm">
Remember me
</label>
</div>
{/* Submit Button */}
<button
type="submit"
disabled={isSubmitting}
className="w-full px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
>
{isSubmitting ? 'Logging in...' : 'Login'}
</button>
{/* Form Status */}
<div className="text-sm text-gray-600">
{isValid && !isSubmitting && (
<span className="text-green-600">Form is valid </span>
)}
</div>
</form>
)
}
/**
* Signup Form Variant
*/
const signupSchema = loginSchema.extend({
confirmPassword: z.string(),
name: z.string().min(2, 'Name must be at least 2 characters'),
}).refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ['confirmPassword'],
})
type SignupFormData = z.infer<typeof signupSchema>
export function BasicSignupForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<SignupFormData>({
resolver: zodResolver(signupSchema),
defaultValues: {
name: '',
email: '',
password: '',
confirmPassword: '',
rememberMe: false,
},
})
const onSubmit = async (data: SignupFormData) => {
console.log('Signup data:', data)
// API call
}
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4 max-w-md mx-auto">
<h2 className="text-2xl font-bold">Sign Up</h2>
{/* Name Field */}
<div>
<label htmlFor="name" className="block text-sm font-medium mb-1">
Full Name
</label>
<input
id="name"
{...register('name')}
className="w-full px-3 py-2 border rounded-md"
placeholder="John Doe"
/>
{errors.name && (
<span role="alert" className="text-sm text-red-600 mt-1 block">
{errors.name.message}
</span>
)}
</div>
{/* Email Field */}
<div>
<label htmlFor="email" className="block text-sm font-medium mb-1">
Email
</label>
<input
id="email"
type="email"
{...register('email')}
className="w-full px-3 py-2 border rounded-md"
placeholder="you@example.com"
/>
{errors.email && (
<span role="alert" className="text-sm text-red-600 mt-1 block">
{errors.email.message}
</span>
)}
</div>
{/* Password Field */}
<div>
<label htmlFor="password" className="block text-sm font-medium mb-1">
Password
</label>
<input
id="password"
type="password"
{...register('password')}
className="w-full px-3 py-2 border rounded-md"
/>
{errors.password && (
<span role="alert" className="text-sm text-red-600 mt-1 block">
{errors.password.message}
</span>
)}
</div>
{/* Confirm Password Field */}
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium mb-1">
Confirm Password
</label>
<input
id="confirmPassword"
type="password"
{...register('confirmPassword')}
className="w-full px-3 py-2 border rounded-md"
/>
{errors.confirmPassword && (
<span role="alert" className="text-sm text-red-600 mt-1 block">
{errors.confirmPassword.message}
</span>
)}
</div>
<button
type="submit"
disabled={isSubmitting}
className="w-full px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-400"
>
{isSubmitting ? 'Creating account...' : 'Sign Up'}
</button>
</form>
)
}

View File

@@ -0,0 +1,303 @@
/**
* Custom Error Display Example
*
* Demonstrates:
* - Custom error component
* - Error summary at top of form
* - Toast notifications for errors
* - Inline vs summary error display
* - Accessible error announcements
* - Icon-based error styling
*/
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { useEffect, useState } from 'react'
const formSchema = z.object({
username: z.string().min(3, 'Username must be at least 3 characters'),
email: z.string().email('Invalid email address'),
password: z.string().min(8, 'Password must be at least 8 characters'),
age: z.number().min(18, 'You must be at least 18 years old'),
})
type FormData = z.infer<typeof formSchema>
/**
* Custom Error Component
*/
function FormError({ message, icon = true }: { message: string; icon?: boolean }) {
return (
<div role="alert" className="flex items-start gap-2 text-sm text-red-600 mt-1">
{icon && (
<svg className="w-4 h-4 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clipRule="evenodd"
/>
</svg>
)}
<span>{message}</span>
</div>
)
}
/**
* Error Summary Component
*/
function ErrorSummary({ errors }: { errors: Record<string, any> }) {
const errorEntries = Object.entries(errors).filter(([key, value]) => value?.message)
if (errorEntries.length === 0) return null
return (
<div
role="alert"
aria-live="assertive"
className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6"
>
<div className="flex items-center gap-2 mb-2">
<svg className="w-5 h-5 text-red-600" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clipRule="evenodd"
/>
</svg>
<h3 className="font-medium text-red-900">
{errorEntries.length} {errorEntries.length === 1 ? 'Error' : 'Errors'} Found
</h3>
</div>
<ul className="list-disc list-inside space-y-1 text-sm text-red-700">
{errorEntries.map(([field, error]) => (
<li key={field}>
<strong className="capitalize">{field}:</strong> {error.message}
</li>
))}
</ul>
</div>
)
}
/**
* Toast Notification for Errors
*/
function ErrorToast({ message, onClose }: { message: string; onClose: () => void }) {
useEffect(() => {
const timer = setTimeout(onClose, 5000)
return () => clearTimeout(timer)
}, [onClose])
return (
<div className="fixed bottom-4 right-4 bg-red-600 text-white px-6 py-4 rounded-lg shadow-lg flex items-start gap-3 max-w-sm animate-slide-in">
<svg className="w-6 h-6 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clipRule="evenodd"
/>
</svg>
<div className="flex-1">
<h4 className="font-medium">Validation Error</h4>
<p className="text-sm mt-1">{message}</p>
</div>
<button
onClick={onClose}
className="text-white hover:text-gray-200"
aria-label="Close notification"
>
</button>
</div>
)
}
/**
* Form with Custom Error Display
*/
export function CustomErrorDisplayForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: {
username: '',
email: '',
password: '',
age: 18,
},
})
const [toastMessage, setToastMessage] = useState<string | null>(null)
const onSubmit = async (data: FormData) => {
console.log('Form data:', data)
setToastMessage('Form submitted successfully!')
}
const onError = (errors: any) => {
// Show toast on validation error
const errorCount = Object.keys(errors).length
setToastMessage(`Please fix ${errorCount} error${errorCount > 1 ? 's' : ''} before submitting`)
}
return (
<div className="max-w-2xl mx-auto">
<form onSubmit={handleSubmit(onSubmit, onError)} className="space-y-6">
<h2 className="text-2xl font-bold">Registration Form</h2>
{/* Error Summary */}
<ErrorSummary errors={errors} />
{/* Username */}
<div>
<label htmlFor="username" className="block text-sm font-medium mb-1">
Username *
</label>
<input
id="username"
{...register('username')}
aria-invalid={errors.username ? 'true' : 'false'}
aria-describedby={errors.username ? 'username-error' : undefined}
className={`w-full px-3 py-2 border rounded-md ${
errors.username ? 'border-red-500 focus:ring-red-500' : 'border-gray-300'
}`}
/>
{errors.username && (
<FormError message={errors.username.message!} />
)}
</div>
{/* Email */}
<div>
<label htmlFor="email" className="block text-sm font-medium mb-1">
Email *
</label>
<input
id="email"
type="email"
{...register('email')}
aria-invalid={errors.email ? 'true' : 'false'}
className={`w-full px-3 py-2 border rounded-md ${
errors.email ? 'border-red-500' : 'border-gray-300'
}`}
/>
{errors.email && (
<FormError message={errors.email.message!} />
)}
</div>
{/* Password */}
<div>
<label htmlFor="password" className="block text-sm font-medium mb-1">
Password *
</label>
<input
id="password"
type="password"
{...register('password')}
aria-invalid={errors.password ? 'true' : 'false'}
className={`w-full px-3 py-2 border rounded-md ${
errors.password ? 'border-red-500' : 'border-gray-300'
}`}
/>
{errors.password && (
<FormError message={errors.password.message!} />
)}
</div>
{/* Age */}
<div>
<label htmlFor="age" className="block text-sm font-medium mb-1">
Age *
</label>
<input
id="age"
type="number"
{...register('age', { valueAsNumber: true })}
aria-invalid={errors.age ? 'true' : 'false'}
className={`w-full px-3 py-2 border rounded-md ${
errors.age ? 'border-red-500' : 'border-gray-300'
}`}
/>
{errors.age && (
<FormError message={errors.age.message!} />
)}
</div>
<button
type="submit"
disabled={isSubmitting}
className="w-full px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-400"
>
{isSubmitting ? 'Submitting...' : 'Submit'}
</button>
</form>
{/* Toast Notification */}
{toastMessage && (
<ErrorToast message={toastMessage} onClose={() => setToastMessage(null)} />
)}
<style>{`
@keyframes slide-in {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.animate-slide-in {
animation: slide-in 0.3s ease-out;
}
`}</style>
</div>
)
}
/**
* Alternative: Grouped Error Display
*/
export function GroupedErrorDisplayForm() {
const { register, handleSubmit, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(formSchema),
})
return (
<form onSubmit={handleSubmit((data) => console.log(data))} className="max-w-2xl mx-auto space-y-6">
<h2 className="text-2xl font-bold">Grouped Error Display</h2>
{/* All errors in single container */}
{Object.keys(errors).length > 0 && (
<div className="bg-red-50 border-l-4 border-red-600 p-4">
<h3 className="font-medium text-red-900 mb-2">Please correct the following:</h3>
<div className="space-y-2">
{Object.entries(errors).map(([field, error]) => (
<div key={field} className="flex items-start gap-2 text-sm text-red-700">
<span className="font-medium capitalize">{field}:</span>
<span>{error.message}</span>
</div>
))}
</div>
</div>
)}
{/* Form fields without individual error messages */}
<input {...register('username')} placeholder="Username" className="w-full px-3 py-2 border rounded" />
<input {...register('email')} placeholder="Email" className="w-full px-3 py-2 border rounded" />
<input {...register('password')} type="password" placeholder="Password" className="w-full px-3 py-2 border rounded" />
<input {...register('age', { valueAsNumber: true })} type="number" placeholder="Age" className="w-full px-3 py-2 border rounded" />
<button type="submit" className="w-full px-4 py-2 bg-blue-600 text-white rounded">
Submit
</button>
</form>
)
}

View File

@@ -0,0 +1,308 @@
/**
* Dynamic Form Fields Example - useFieldArray
*
* Demonstrates:
* - useFieldArray for dynamic add/remove functionality
* - Array validation with Zod
* - Proper key usage (field.id, not index)
* - Nested field error handling
* - Add, remove, update, insert operations
*/
import { useForm, useFieldArray } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
// Schema for contact list
const contactSchema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Invalid email'),
phone: z.string().regex(/^\+?[1-9]\d{1,14}$/, 'Invalid phone number').optional(),
isPrimary: z.boolean().optional(),
})
const contactListSchema = z.object({
contacts: z.array(contactSchema)
.min(1, 'At least one contact is required')
.max(10, 'Maximum 10 contacts allowed'),
})
type ContactListData = z.infer<typeof contactListSchema>
export function DynamicContactList() {
const {
register,
control,
handleSubmit,
formState: { errors },
} = useForm<ContactListData>({
resolver: zodResolver(contactListSchema),
defaultValues: {
contacts: [{ name: '', email: '', phone: '', isPrimary: false }],
},
})
const { fields, append, remove, insert, update } = useFieldArray({
control,
name: 'contacts',
})
const onSubmit = (data: ContactListData) => {
console.log('Contacts:', data)
}
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6 max-w-2xl mx-auto">
<h2 className="text-2xl font-bold">Contact List</h2>
{/* Array error (min/max length) */}
{errors.contacts && !Array.isArray(errors.contacts) && (
<div role="alert" className="text-sm text-red-600 bg-red-50 p-3 rounded">
{errors.contacts.message}
</div>
)}
<div className="space-y-4">
{fields.map((field, index) => (
<div
key={field.id} // IMPORTANT: Use field.id, not index
className="border rounded-lg p-4 space-y-3"
>
<div className="flex justify-between items-center">
<h3 className="font-medium">Contact {index + 1}</h3>
<button
type="button"
onClick={() => remove(index)}
className="text-red-600 hover:text-red-800 text-sm"
disabled={fields.length === 1} // Require at least one contact
>
Remove
</button>
</div>
{/* Name */}
<div>
<label htmlFor={`contacts.${index}.name`} className="block text-sm font-medium mb-1">
Name *
</label>
<input
id={`contacts.${index}.name`}
{...register(`contacts.${index}.name` as const)}
className="w-full px-3 py-2 border rounded-md"
/>
{errors.contacts?.[index]?.name && (
<span role="alert" className="text-sm text-red-600 mt-1 block">
{errors.contacts[index]?.name?.message}
</span>
)}
</div>
{/* Email */}
<div>
<label htmlFor={`contacts.${index}.email`} className="block text-sm font-medium mb-1">
Email *
</label>
<input
id={`contacts.${index}.email`}
type="email"
{...register(`contacts.${index}.email` as const)}
className="w-full px-3 py-2 border rounded-md"
/>
{errors.contacts?.[index]?.email && (
<span role="alert" className="text-sm text-red-600 mt-1 block">
{errors.contacts[index]?.email?.message}
</span>
)}
</div>
{/* Phone */}
<div>
<label htmlFor={`contacts.${index}.phone`} className="block text-sm font-medium mb-1">
Phone (Optional)
</label>
<input
id={`contacts.${index}.phone`}
type="tel"
{...register(`contacts.${index}.phone` as const)}
placeholder="+1234567890"
className="w-full px-3 py-2 border rounded-md"
/>
{errors.contacts?.[index]?.phone && (
<span role="alert" className="text-sm text-red-600 mt-1 block">
{errors.contacts[index]?.phone?.message}
</span>
)}
</div>
{/* Primary Contact Checkbox */}
<div className="flex items-center">
<input
id={`contacts.${index}.isPrimary`}
type="checkbox"
{...register(`contacts.${index}.isPrimary` as const)}
className="h-4 w-4 rounded"
/>
<label htmlFor={`contacts.${index}.isPrimary`} className="ml-2 text-sm">
Primary contact
</label>
</div>
</div>
))}
</div>
{/* Add Contact Button */}
<div className="flex gap-2">
<button
type="button"
onClick={() => append({ name: '', email: '', phone: '', isPrimary: false })}
disabled={fields.length >= 10}
className="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 disabled:bg-gray-400"
>
Add Contact
</button>
<button
type="button"
onClick={() => insert(0, { name: '', email: '', phone: '', isPrimary: false })}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
>
Add at Top
</button>
</div>
{/* Submit */}
<button type="submit" className="w-full px-4 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700">
Save Contacts
</button>
</form>
)
}
/**
* Advanced Example: Skills with Custom Add
*/
const skillSchema = z.object({
name: z.string().min(1, 'Skill name is required'),
level: z.enum(['beginner', 'intermediate', 'advanced', 'expert']),
yearsOfExperience: z.number().int().min(0).max(50),
})
const skillsFormSchema = z.object({
skills: z.array(skillSchema).min(1, 'Add at least one skill'),
})
type SkillsFormData = z.infer<typeof skillsFormSchema>
export function DynamicSkillsForm() {
const { register, control, handleSubmit, formState: { errors } } = useForm<SkillsFormData>({
resolver: zodResolver(skillsFormSchema),
defaultValues: {
skills: [],
},
})
const { fields, append, remove } = useFieldArray({
control,
name: 'skills',
})
// Preset skill templates
const addPresetSkill = (skillName: string) => {
append({
name: skillName,
level: 'intermediate',
yearsOfExperience: 1,
})
}
const onSubmit = (data: SkillsFormData) => {
console.log('Skills:', data)
}
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6 max-w-2xl mx-auto">
<h2 className="text-2xl font-bold">Your Skills</h2>
{errors.skills && !Array.isArray(errors.skills) && (
<div role="alert" className="text-sm text-red-600">
{errors.skills.message}
</div>
)}
{/* Preset Skills */}
<div className="space-y-2">
<h3 className="text-sm font-medium">Quick Add:</h3>
<div className="flex flex-wrap gap-2">
{['React', 'TypeScript', 'Node.js', 'Python', 'SQL'].map((skill) => (
<button
key={skill}
type="button"
onClick={() => addPresetSkill(skill)}
className="px-3 py-1 bg-gray-200 rounded-full text-sm hover:bg-gray-300"
>
+ {skill}
</button>
))}
</div>
</div>
{/* Skills List */}
<div className="space-y-3">
{fields.map((field, index) => (
<div key={field.id} className="border rounded p-3 flex gap-3 items-start">
<div className="flex-1 space-y-2">
<input
{...register(`skills.${index}.name` as const)}
placeholder="Skill name"
className="w-full px-2 py-1 border rounded text-sm"
/>
{errors.skills?.[index]?.name && (
<span className="text-xs text-red-600">{errors.skills[index]?.name?.message}</span>
)}
<div className="grid grid-cols-2 gap-2">
<select
{...register(`skills.${index}.level` as const)}
className="px-2 py-1 border rounded text-sm"
>
<option value="beginner">Beginner</option>
<option value="intermediate">Intermediate</option>
<option value="advanced">Advanced</option>
<option value="expert">Expert</option>
</select>
<input
type="number"
{...register(`skills.${index}.yearsOfExperience` as const, { valueAsNumber: true })}
placeholder="Years"
className="px-2 py-1 border rounded text-sm"
/>
</div>
</div>
<button
type="button"
onClick={() => remove(index)}
className="text-red-600 hover:text-red-800 text-sm px-2"
>
</button>
</div>
))}
</div>
{/* Custom Add */}
<button
type="button"
onClick={() => append({ name: '', level: 'beginner', yearsOfExperience: 0 })}
className="w-full px-4 py-2 border-2 border-dashed border-gray-300 rounded-md hover:border-gray-400 text-gray-600"
>
+ Add Custom Skill
</button>
<button type="submit" className="w-full px-4 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700">
Save Skills
</button>
</form>
)
}

View File

@@ -0,0 +1,367 @@
/**
* Multi-Step Form Example (Wizard)
*
* Demonstrates:
* - Multi-step form with per-step validation
* - Progress tracking
* - Step navigation (next, previous)
* - Partial schema validation
* - Combined schema for final submission
* - Preserving form state across steps
*/
import { useState } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
// Step 1: Personal Information
const step1Schema = z.object({
firstName: z.string().min(2, 'First name must be at least 2 characters'),
lastName: z.string().min(2, 'Last name must be at least 2 characters'),
email: z.string().email('Invalid email address'),
phone: z.string().regex(/^\+?[1-9]\d{1,14}$/, 'Invalid phone number'),
})
// Step 2: Address
const step2Schema = z.object({
street: z.string().min(1, 'Street is required'),
city: z.string().min(1, 'City is required'),
state: z.string().min(2, 'State must be at least 2 characters'),
zipCode: z.string().regex(/^\d{5}(-\d{4})?$/, 'Invalid ZIP code'),
})
// Step 3: Account
const step3Schema = z.object({
username: z.string()
.min(3, 'Username must be at least 3 characters')
.regex(/^[a-zA-Z0-9_]+$/, 'Username can only contain letters, numbers, and underscores'),
password: z.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Password must contain uppercase letter')
.regex(/[0-9]/, 'Password must contain number'),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ['confirmPassword'],
})
// Combined schema for final validation
const fullFormSchema = step1Schema.merge(step2Schema).merge(step3Schema)
type FormData = z.infer<typeof fullFormSchema>
type Step1Data = z.infer<typeof step1Schema>
type Step2Data = z.infer<typeof step2Schema>
type Step3Data = z.infer<typeof step3Schema>
const TOTAL_STEPS = 3
export function MultiStepRegistrationForm() {
const [currentStep, setCurrentStep] = useState(1)
const {
register,
handleSubmit,
trigger,
getValues,
formState: { errors, isSubmitting },
} = useForm<FormData>({
resolver: zodResolver(fullFormSchema),
mode: 'onChange', // Validate as user types
defaultValues: {
firstName: '',
lastName: '',
email: '',
phone: '',
street: '',
city: '',
state: '',
zipCode: '',
username: '',
password: '',
confirmPassword: '',
},
})
// Navigate to next step
const nextStep = async () => {
let fieldsToValidate: (keyof FormData)[] = []
if (currentStep === 1) {
fieldsToValidate = ['firstName', 'lastName', 'email', 'phone']
} else if (currentStep === 2) {
fieldsToValidate = ['street', 'city', 'state', 'zipCode']
}
// Trigger validation for current step fields
const isValid = await trigger(fieldsToValidate)
if (isValid) {
setCurrentStep((prev) => Math.min(prev + 1, TOTAL_STEPS))
}
}
// Navigate to previous step
const prevStep = () => {
setCurrentStep((prev) => Math.max(prev - 1, 1))
}
// Final form submission
const onSubmit = async (data: FormData) => {
console.log('Complete form data:', data)
// Make API call
alert('Form submitted successfully!')
}
// Calculate progress percentage
const progressPercentage = (currentStep / TOTAL_STEPS) * 100
return (
<div className="max-w-2xl mx-auto">
{/* Progress Bar */}
<div className="mb-8">
<div className="flex justify-between mb-2">
<span className="text-sm font-medium text-gray-700">
Step {currentStep} of {TOTAL_STEPS}
</span>
<span className="text-sm font-medium text-gray-700">
{Math.round(progressPercentage)}% Complete
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
style={{ width: `${progressPercentage}%` }}
/>
</div>
{/* Step Indicators */}
<div className="flex justify-between mt-4">
{[1, 2, 3].map((step) => (
<div
key={step}
className={`flex items-center ${
step < TOTAL_STEPS ? 'flex-1' : ''
}`}
>
<div
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
step < currentStep
? 'bg-green-600 text-white'
: step === currentStep
? 'bg-blue-600 text-white'
: 'bg-gray-300 text-gray-600'
}`}
>
{step < currentStep ? '✓' : step}
</div>
{step < TOTAL_STEPS && (
<div
className={`flex-1 h-1 mx-2 ${
step < currentStep ? 'bg-green-600' : 'bg-gray-300'
}`}
/>
)}
</div>
))}
</div>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{/* Step 1: Personal Information */}
{currentStep === 1 && (
<div className="space-y-4">
<h2 className="text-2xl font-bold">Personal Information</h2>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1">First Name *</label>
<input
{...register('firstName')}
className="w-full px-3 py-2 border rounded-md"
/>
{errors.firstName && (
<span className="text-sm text-red-600">{errors.firstName.message}</span>
)}
</div>
<div>
<label className="block text-sm font-medium mb-1">Last Name *</label>
<input
{...register('lastName')}
className="w-full px-3 py-2 border rounded-md"
/>
{errors.lastName && (
<span className="text-sm text-red-600">{errors.lastName.message}</span>
)}
</div>
</div>
<div>
<label className="block text-sm font-medium mb-1">Email *</label>
<input
type="email"
{...register('email')}
className="w-full px-3 py-2 border rounded-md"
/>
{errors.email && (
<span className="text-sm text-red-600">{errors.email.message}</span>
)}
</div>
<div>
<label className="block text-sm font-medium mb-1">Phone *</label>
<input
type="tel"
{...register('phone')}
placeholder="+1234567890"
className="w-full px-3 py-2 border rounded-md"
/>
{errors.phone && (
<span className="text-sm text-red-600">{errors.phone.message}</span>
)}
</div>
</div>
)}
{/* Step 2: Address */}
{currentStep === 2 && (
<div className="space-y-4">
<h2 className="text-2xl font-bold">Address</h2>
<div>
<label className="block text-sm font-medium mb-1">Street Address *</label>
<input
{...register('street')}
className="w-full px-3 py-2 border rounded-md"
/>
{errors.street && (
<span className="text-sm text-red-600">{errors.street.message}</span>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1">City *</label>
<input
{...register('city')}
className="w-full px-3 py-2 border rounded-md"
/>
{errors.city && (
<span className="text-sm text-red-600">{errors.city.message}</span>
)}
</div>
<div>
<label className="block text-sm font-medium mb-1">State *</label>
<input
{...register('state')}
className="w-full px-3 py-2 border rounded-md"
/>
{errors.state && (
<span className="text-sm text-red-600">{errors.state.message}</span>
)}
</div>
</div>
<div>
<label className="block text-sm font-medium mb-1">ZIP Code *</label>
<input
{...register('zipCode')}
placeholder="12345 or 12345-6789"
className="w-full px-3 py-2 border rounded-md"
/>
{errors.zipCode && (
<span className="text-sm text-red-600">{errors.zipCode.message}</span>
)}
</div>
</div>
)}
{/* Step 3: Account */}
{currentStep === 3 && (
<div className="space-y-4">
<h2 className="text-2xl font-bold">Create Account</h2>
<div>
<label className="block text-sm font-medium mb-1">Username *</label>
<input
{...register('username')}
className="w-full px-3 py-2 border rounded-md"
/>
{errors.username && (
<span className="text-sm text-red-600">{errors.username.message}</span>
)}
</div>
<div>
<label className="block text-sm font-medium mb-1">Password *</label>
<input
type="password"
{...register('password')}
className="w-full px-3 py-2 border rounded-md"
/>
{errors.password && (
<span className="text-sm text-red-600">{errors.password.message}</span>
)}
</div>
<div>
<label className="block text-sm font-medium mb-1">Confirm Password *</label>
<input
type="password"
{...register('confirmPassword')}
className="w-full px-3 py-2 border rounded-md"
/>
{errors.confirmPassword && (
<span className="text-sm text-red-600">{errors.confirmPassword.message}</span>
)}
</div>
{/* Summary */}
<div className="mt-6 p-4 bg-gray-50 rounded-md">
<h3 className="font-medium mb-2">Review Your Information:</h3>
<div className="text-sm space-y-1">
<p><strong>Name:</strong> {getValues('firstName')} {getValues('lastName')}</p>
<p><strong>Email:</strong> {getValues('email')}</p>
<p><strong>Phone:</strong> {getValues('phone')}</p>
<p><strong>Address:</strong> {getValues('street')}, {getValues('city')}, {getValues('state')} {getValues('zipCode')}</p>
<p><strong>Username:</strong> {getValues('username')}</p>
</div>
</div>
</div>
)}
{/* Navigation Buttons */}
<div className="flex justify-between pt-4">
<button
type="button"
onClick={prevStep}
disabled={currentStep === 1}
className="px-6 py-2 border rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
{currentStep < TOTAL_STEPS ? (
<button
type="button"
onClick={nextStep}
className="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
>
Next
</button>
) : (
<button
type="submit"
disabled={isSubmitting}
className="px-6 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 disabled:bg-gray-400"
>
{isSubmitting ? 'Submitting...' : 'Complete Registration'}
</button>
)}
</div>
</form>
</div>
)
}

35
templates/package.json Normal file
View File

@@ -0,0 +1,35 @@
{
"name": "react-hook-form-zod-example",
"version": "1.0.0",
"description": "Example project demonstrating React Hook Form + Zod validation",
"private": true,
"dependencies": {
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-hook-form": "^7.66.1",
"zod": "^4.1.12",
"@hookform/resolvers": "^5.2.2"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"typescript": "^5.7.0",
"vite": "^6.3.0",
"@vitejs/plugin-react": "^4.3.0"
},
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"keywords": [
"react",
"react-hook-form",
"zod",
"validation",
"forms",
"typescript"
],
"author": "",
"license": "MIT"
}

View File

@@ -0,0 +1,313 @@
/**
* Server-Side Validation Example
*
* Demonstrates:
* - Using the SAME Zod schema on server
* - Single source of truth for validation
* - Error mapping from server to client
* - Type-safe validation on both sides
*/
import { z } from 'zod'
/**
* SHARED SCHEMA - Use this exact schema on both client and server
* Define it in a shared file (e.g., schemas/user.ts) and import on both sides
*/
export const userRegistrationSchema = z.object({
username: z.string()
.min(3, 'Username must be at least 3 characters')
.max(20, 'Username must not exceed 20 characters')
.regex(/^[a-zA-Z0-9_]+$/, 'Username can only contain letters, numbers, and underscores'),
email: z.string()
.email('Invalid email address'),
password: z.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
.regex(/[a-z]/, 'Password must contain at least one lowercase letter')
.regex(/[0-9]/, 'Password must contain at least one number'),
age: z.number()
.int('Age must be a whole number')
.min(13, 'You must be at least 13 years old')
.max(120, 'Invalid age'),
}).refine((data) => {
// Custom validation: check if username is blacklisted
const blacklistedUsernames = ['admin', 'root', 'system']
return !blacklistedUsernames.includes(data.username.toLowerCase())
}, {
message: 'This username is not allowed',
path: ['username'],
})
type UserRegistrationData = z.infer<typeof userRegistrationSchema>
/**
* SERVER-SIDE VALIDATION (Next.js API Route Example)
*/
export async function POST(request: Request) {
try {
const body = await request.json()
// 1. Parse and validate with Zod
const validatedData = userRegistrationSchema.parse(body)
// 2. Additional server-only validation (database checks, etc.)
const usernameExists = await checkUsernameExists(validatedData.username)
if (usernameExists) {
return Response.json(
{
success: false,
errors: {
username: 'Username is already taken',
},
},
{ status: 400 }
)
}
const emailExists = await checkEmailExists(validatedData.email)
if (emailExists) {
return Response.json(
{
success: false,
errors: {
email: 'Email is already registered',
},
},
{ status: 400 }
)
}
// 3. Proceed with registration
const user = await createUser(validatedData)
return Response.json({
success: true,
user: {
id: user.id,
username: user.username,
email: user.email,
},
})
} catch (error) {
// 4. Handle Zod validation errors
if (error instanceof z.ZodError) {
return Response.json(
{
success: false,
errors: error.flatten().fieldErrors,
},
{ status: 400 }
)
}
// 5. Handle other errors
console.error('Registration error:', error)
return Response.json(
{
success: false,
message: 'An unexpected error occurred',
},
{ status: 500 }
)
}
}
/**
* SERVER-SIDE VALIDATION (Node.js/Express Example)
*/
import express from 'express'
const app = express()
app.post('/api/register', async (req, res) => {
try {
// Parse and validate
const validatedData = userRegistrationSchema.parse(req.body)
// Server-only checks
const usernameExists = await checkUsernameExists(validatedData.username)
if (usernameExists) {
return res.status(400).json({
success: false,
errors: {
username: 'Username is already taken',
},
})
}
// Create user
const user = await createUser(validatedData)
res.json({
success: true,
user,
})
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).json({
success: false,
errors: error.flatten().fieldErrors,
})
}
console.error('Registration error:', error)
res.status(500).json({
success: false,
message: 'An unexpected error occurred',
})
}
})
/**
* SERVER-SIDE VALIDATION (Cloudflare Workers + Hono Example)
*/
import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
const app = new Hono()
app.post('/api/register', zValidator('json', userRegistrationSchema), async (c) => {
// Data is already validated by zValidator middleware
const validatedData = c.req.valid('json')
// Server-only checks
const usernameExists = await checkUsernameExists(validatedData.username)
if (usernameExists) {
return c.json(
{
success: false,
errors: {
username: 'Username is already taken',
},
},
400
)
}
// Create user
const user = await createUser(validatedData)
return c.json({
success: true,
user,
})
})
/**
* CLIENT-SIDE INTEGRATION WITH SERVER ERRORS
*/
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
function RegistrationForm() {
const {
register,
handleSubmit,
setError,
formState: { errors, isSubmitting },
} = useForm<UserRegistrationData>({
resolver: zodResolver(userRegistrationSchema),
defaultValues: {
username: '',
email: '',
password: '',
age: 18,
},
})
const onSubmit = async (data: UserRegistrationData) => {
try {
const response = await fetch('/api/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
const result = await response.json()
if (!result.success) {
// Map server errors to form fields
if (result.errors) {
Object.entries(result.errors).forEach(([field, message]) => {
setError(field as keyof UserRegistrationData, {
type: 'server',
message: Array.isArray(message) ? message[0] : message as string,
})
})
} else {
// Generic error
setError('root', {
type: 'server',
message: result.message || 'Registration failed',
})
}
return
}
// Success - redirect or show success message
console.log('Registration successful:', result.user)
} catch (error) {
setError('root', {
type: 'server',
message: 'Network error. Please try again.',
})
}
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
{errors.root && (
<div role="alert" className="error-banner">
{errors.root.message}
</div>
)}
{/* Form fields */}
<input {...register('username')} />
{errors.username && <span>{errors.username.message}</span>}
<input type="email" {...register('email')} />
{errors.email && <span>{errors.email.message}</span>}
<input type="password" {...register('password')} />
{errors.password && <span>{errors.password.message}</span>}
<input type="number" {...register('age', { valueAsNumber: true })} />
{errors.age && <span>{errors.age.message}</span>}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Registering...' : 'Register'}
</button>
</form>
)
}
/**
* Helper functions (implement according to your database)
*/
async function checkUsernameExists(username: string): Promise<boolean> {
// Database query
return false
}
async function checkEmailExists(email: string): Promise<boolean> {
// Database query
return false
}
async function createUser(data: UserRegistrationData) {
// Create user in database
return { id: '1', ...data }
}
/**
* KEY BENEFITS OF SERVER-SIDE VALIDATION:
*
* 1. Security - Client validation can be bypassed, server validation cannot
* 2. Single Source of Truth - Same schema on client and server
* 3. Type Safety - TypeScript types automatically inferred from schema
* 4. Consistency - Same validation rules applied everywhere
* 5. Database Checks - Server can validate against database (unique username, etc.)
*/

351
templates/shadcn-form.tsx Normal file
View File

@@ -0,0 +1,351 @@
/**
* shadcn/ui Form Component Integration
*
* Demonstrates:
* - shadcn/ui Form component with React Hook Form + Zod
* - FormField, FormItem, FormLabel, FormControl, FormMessage components
* - Type-safe form with proper error handling
* - Accessible form structure
*
* Installation:
* npx shadcn@latest add form input button
*/
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'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Checkbox } from '@/components/ui/checkbox'
// Define schema
const profileFormSchema = z.object({
username: z.string()
.min(2, { message: 'Username must be at least 2 characters.' })
.max(30, { message: 'Username must not be longer than 30 characters.' }),
email: z.string()
.email({ message: 'Please enter a valid email address.' }),
bio: z.string()
.max(160, { message: 'Bio must not be longer than 160 characters.' })
.optional(),
role: z.enum(['admin', 'user', 'guest'], {
required_error: 'Please select a role.',
}),
notifications: z.boolean().default(false).optional(),
})
type ProfileFormValues = z.infer<typeof profileFormSchema>
export function ShadcnProfileForm() {
const form = useForm<ProfileFormValues>({
resolver: zodResolver(profileFormSchema),
defaultValues: {
username: '',
email: '',
bio: '',
notifications: false,
},
})
function onSubmit(data: ProfileFormValues) {
console.log('Form submitted:', data)
// Make API call
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8 max-w-2xl mx-auto">
<h2 className="text-3xl font-bold">Profile Settings</h2>
{/* Username Field */}
<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>
)}
/>
{/* Email Field */}
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" placeholder="you@example.com" {...field} />
</FormControl>
<FormDescription>
We'll never share your email with anyone.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Bio Field (Textarea) */}
<FormField
control={form.control}
name="bio"
render={({ field }) => (
<FormItem>
<FormLabel>Bio</FormLabel>
<FormControl>
<Textarea
placeholder="Tell us a little bit about yourself"
className="resize-none"
{...field}
/>
</FormControl>
<FormDescription>
You can write up to 160 characters.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Role Field (Select) */}
<FormField
control={form.control}
name="role"
render={({ field }) => (
<FormItem>
<FormLabel>Role</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a role" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="user">User</SelectItem>
<SelectItem value="guest">Guest</SelectItem>
</SelectContent>
</Select>
<FormDescription>
Choose your account type.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Notifications Field (Checkbox) */}
<FormField
control={form.control}
name="notifications"
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>
Email notifications
</FormLabel>
<FormDescription>
Receive email notifications about your account activity.
</FormDescription>
</div>
</FormItem>
)}
/>
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? 'Saving...' : 'Update profile'}
</Button>
</form>
</Form>
)
}
/**
* Multiple Field Types Example
*/
const settingsFormSchema = z.object({
name: z.string().min(1, 'Name is required'),
language: z.string(),
theme: z.enum(['light', 'dark', 'system']),
emailPreferences: z.object({
marketing: z.boolean().default(false),
updates: z.boolean().default(true),
security: z.boolean().default(true),
}),
})
type SettingsFormValues = z.infer<typeof settingsFormSchema>
export function ShadcnSettingsForm() {
const form = useForm<SettingsFormValues>({
resolver: zodResolver(settingsFormSchema),
defaultValues: {
name: '',
language: 'en',
theme: 'system',
emailPreferences: {
marketing: false,
updates: true,
security: true,
},
},
})
function onSubmit(data: SettingsFormValues) {
console.log('Settings updated:', data)
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8 max-w-2xl mx-auto">
<h2 className="text-3xl font-bold">Settings</h2>
{/* Name */}
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Display Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Theme */}
<FormField
control={form.control}
name="theme"
render={({ field }) => (
<FormItem>
<FormLabel>Theme</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="light">Light</SelectItem>
<SelectItem value="dark">Dark</SelectItem>
<SelectItem value="system">System</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{/* Email Preferences (Nested Object with Checkboxes) */}
<div className="space-y-4">
<h3 className="text-lg font-medium">Email Preferences</h3>
<FormField
control={form.control}
name="emailPreferences.marketing"
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>Marketing emails</FormLabel>
<FormDescription>
Receive emails about new products and features.
</FormDescription>
</div>
</FormItem>
)}
/>
<FormField
control={form.control}
name="emailPreferences.updates"
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>Update emails</FormLabel>
<FormDescription>
Receive emails about your account updates.
</FormDescription>
</div>
</FormItem>
)}
/>
<FormField
control={form.control}
name="emailPreferences.security"
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>Security emails</FormLabel>
<FormDescription>
Receive emails about your account security (recommended).
</FormDescription>
</div>
</FormItem>
)}
/>
</div>
<Button type="submit">Save settings</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 https://ui.shadcn.com/docs/components/form for latest guidance.
*/