
Form State Patterns
- 13 installs
- 213 repo stars
- Updated August 4, 2026
- yonatangross/orchestkit
Helps with ai & agent building tasks.
About
form-state-patterns is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- form-state-patterns
- AI & Agent Building
- AI-coding skill
Form State Patterns by the numbers
- 13 all-time installs (skills.sh)
- Ranked #11,389 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/yonatangross/orchestkit --skill form-state-patternsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 13 |
|---|---|
| repo stars | ★ 213 |
| Last updated | August 4, 2026 |
| Repository | yonatangross/orchestkit ↗ |
What it does
Helps with ai & agent building tasks.
Files
Form State Patterns
Production form patterns with React Hook Form v7 + Zod - type-safe, performant, accessible.
Overview
- Complex forms with validation
- Multi-step wizards
- Dynamic field arrays
- Server-side validation
- Async field validation
- Forms with file uploads
Core Patterns
1. Basic Form with Zod Schema
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const userSchema = z.object({
email: z.string().email('Invalid email'),
password: z.string().min(8, 'Min 8 characters'),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ['confirmPassword'],
});
type UserForm = z.infer<typeof userSchema>;
function SignupForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<UserForm>({
resolver: zodResolver(userSchema),
defaultValues: { email: '', password: '', confirmPassword: '' },
});
const onSubmit = async (data: UserForm) => {
await api.signup(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email')} aria-invalid={!!errors.email} />
{errors.email && <span role="alert">{errors.email.message}</span>}
<input type="password" {...register('password')} />
{errors.password && <span role="alert">{errors.password.message}</span>}
<input type="password" {...register('confirmPassword')} />
{errors.confirmPassword && <span role="alert">{errors.confirmPassword.message}</span>}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Submitting...' : 'Sign Up'}
</button>
</form>
);
}2. Field Arrays (Dynamic Fields)
import { useFieldArray, useForm } from 'react-hook-form';
const orderSchema = z.object({
items: z.array(z.object({
productId: z.string().min(1),
quantity: z.number().min(1).max(100),
})).min(1, 'At least one item required'),
});
function OrderForm() {
const { control, register, handleSubmit } = useForm({
resolver: zodResolver(orderSchema),
defaultValues: { items: [{ productId: '', quantity: 1 }] },
});
const { fields, append, remove } = useFieldArray({
control,
name: 'items',
});
return (
<form onSubmit={handleSubmit(onSubmit)}>
{fields.map((field, index) => (
<div key={field.id}>
<input {...register(`items.${index}.productId`)} />
<input
type="number"
{...register(`items.${index}.quantity`, { valueAsNumber: true })}
/>
<button type="button" onClick={() => remove(index)}>Remove</button>
</div>
))}
<button type="button" onClick={() => append({ productId: '', quantity: 1 })}>
Add Item
</button>
<button type="submit">Submit Order</button>
</form>
);
}3. Async Field Validation
const usernameSchema = z.object({
username: z.string()
.min(3)
.refine(async (value) => {
const available = await checkUsernameAvailability(value);
return available;
}, 'Username already taken'),
});
// Or with mode: 'onBlur' for better UX
const { register } = useForm({
resolver: zodResolver(usernameSchema),
mode: 'onBlur', // Validate on blur, not on every keystroke
});4. Server Actions (React 19 / Next.js)
// actions.ts
'use server';
import { z } from 'zod';
const contactSchema = z.object({
name: z.string().min(2),
email: z.string().email(),
message: z.string().min(10),
});
export async function submitContact(formData: FormData) {
const result = contactSchema.safeParse({
name: formData.get('name'),
email: formData.get('email'),
message: formData.get('message'),
});
if (!result.success) {
return { errors: result.error.flatten().fieldErrors };
}
await saveContact(result.data);
return { success: true };
}
// Component
'use client';
import { useActionState } from 'react';
import { submitContact } from './actions';
function ContactForm() {
const [state, formAction, isPending] = useActionState(submitContact, null);
return (
<form action={formAction}>
<input name="name" />
{state?.errors?.name && <span>{state.errors.name[0]}</span>}
<input name="email" />
{state?.errors?.email && <span>{state.errors.email[0]}</span>}
<textarea name="message" />
{state?.errors?.message && <span>{state.errors.message[0]}</span>}
<button type="submit" disabled={isPending}>
{isPending ? 'Sending...' : 'Send'}
</button>
</form>
);
}5. Multi-Step Wizard
const steps = ['personal', 'address', 'payment'] as const;
const wizardSchema = z.object({
personal: z.object({
firstName: z.string().min(1),
lastName: z.string().min(1),
}),
address: z.object({
street: z.string().min(1),
city: z.string().min(1),
}),
payment: z.object({
cardNumber: z.string().length(16),
}),
});
function WizardForm() {
const [step, setStep] = useState(0);
const methods = useForm({
resolver: zodResolver(wizardSchema),
mode: 'onTouched',
});
const nextStep = async () => {
const stepKey = steps[step];
const isValid = await methods.trigger(stepKey);
if (isValid) setStep((s) => Math.min(s + 1, steps.length - 1));
};
return (
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit(onSubmit)}>
{step === 0 && <PersonalStep />}
{step === 1 && <AddressStep />}
{step === 2 && <PaymentStep />}
<div>
{step > 0 && <button type="button" onClick={() => setStep(s => s - 1)}>Back</button>}
{step < steps.length - 1 && <button type="button" onClick={nextStep}>Next</button>}
{step === steps.length - 1 && <button type="submit">Submit</button>}
</div>
</form>
</FormProvider>
);
}6. File Upload with Preview
const fileSchema = z.object({
avatar: z
.instanceof(FileList)
.refine((files) => files.length === 1, 'File required')
.refine((files) => files[0]?.size <= 5_000_000, 'Max 5MB')
.refine(
(files) => ['image/jpeg', 'image/png'].includes(files[0]?.type),
'Only JPEG/PNG'
),
});
function AvatarUpload() {
const [preview, setPreview] = useState<string | null>(null);
const { register, watch } = useForm({ resolver: zodResolver(fileSchema) });
const avatar = watch('avatar');
useEffect(() => {
if (avatar?.[0]) {
setPreview(URL.createObjectURL(avatar[0]));
}
}, [avatar]);
return (
<>
{preview && <img src={preview} alt="Preview" />}
<input type="file" accept="image/*" {...register('avatar')} />
</>
);
}7. Controlled Components Integration
import { Controller } from 'react-hook-form';
import { DatePicker } from '@/components/ui/date-picker';
function EventForm() {
const { control } = useForm();
return (
<Controller
name="eventDate"
control={control}
render={({ field, fieldState }) => (
<DatePicker
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
error={fieldState.error?.message}
/>
)}
/>
);
}Performance Optimizations
// Isolate re-renders with Controller
<Controller name="email" control={control} render={...} />
// Use mode: 'onBlur' instead of 'onChange'
useForm({ mode: 'onBlur' });
// Avoid watching entire form
const email = watch('email'); // Good: specific field
const form = watch(); // Bad: entire form triggers re-renderAccessibility Checklist
- [ ] All inputs have associated labels
- [ ] Error messages use
role="alert" - [ ] Invalid inputs have
aria-invalid="true" - [ ] Submit button shows loading state
- [ ] Focus management on error
Quick Reference
// ✅ Basic form setup with Zod resolver
const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: { name: '', email: '' },
mode: 'onBlur', // Validate on blur, not every keystroke
});
// ✅ Register inputs with accessibility
<input
{...register('email')}
aria-invalid={!!errors.email}
aria-describedby={errors.email ? 'email-error' : undefined}
/>
{errors.email && <p id="email-error" role="alert">{errors.email.message}</p>}
// ✅ Controller for third-party components
<Controller
name="date"
control={control}
render={({ field, fieldState }) => (
<DatePicker value={field.value} onChange={field.onChange} error={fieldState.error} />
)}
/>
// ✅ useActionState for React 19 Server Actions
const [state, formAction, isPending] = useActionState(serverAction, initialState);
// ❌ NEVER watch entire form (causes full re-render)
const allValues = watch(); // BAD
// ❌ NEVER use index as key in field arrays
fields.map((field, index) => <div key={index}>...</div>) // BAD - use field.idKey Decisions
| Decision | Option A | Option B | Recommendation |
|---|---|---|---|
| Validation library | Yup | Zod | Zod - better TypeScript inference, smaller bundle |
| Validation mode | onChange | onBlur | onBlur - better performance, less noise |
| Complex components | register | Controller | Controller - for non-native inputs |
| Server validation | Client-only | Server Actions | Server Actions - for mutations with React 19 |
| Form state lib | Formik | React Hook Form | RHF - better performance, less re-renders |
| Field arrays | Manual state | useFieldArray | useFieldArray - built-in add/remove/swap |
Anti-Patterns (FORBIDDEN)
// ❌ FORBIDDEN: Watching entire form
const form = watch(); // Re-renders on EVERY change to ANY field
// ❌ FORBIDDEN: Using index as key in field arrays
{fields.map((field, index) => (
<div key={index}> // WRONG - will cause bugs on reorder/remove
<input {...register(`items.${index}.name`)} />
</div>
))}
// ✅ CORRECT: Use field.id
{fields.map((field, index) => (
<div key={field.id}>
<input {...register(`items.${index}.name`)} />
</div>
))}
// ❌ FORBIDDEN: Missing defaultValues for all fields
useForm({
resolver: zodResolver(schema),
// Missing defaultValues causes uncontrolled->controlled warning
});
// ❌ FORBIDDEN: Using native validation with Zod
<input type="email" required {...register('email')} /> // Conflicts with Zod
// ✅ CORRECT: Disable native validation
<form onSubmit={handleSubmit(onSubmit)} noValidate>
// ❌ FORBIDDEN: setError without manual clearErrors
const onSubmit = async (data) => {
const result = await api.submit(data);
if (!result.success) {
setError('email', { message: 'Email taken' });
// Missing clearErrors on next submit!
}
};
// ❌ FORBIDDEN: Async validation on every keystroke
const schema = z.object({
username: z.string().refine(async (val) => {
return await checkAvailable(val); // Fires on every character!
}),
});
// ✅ CORRECT: Use mode: 'onBlur' or debounce
useForm({ mode: 'onBlur' });
// ❌ FORBIDDEN: Missing error messages in Zod
const schema = z.object({
email: z.string().email(), // Generic "Invalid" error
});
// ✅ CORRECT: Custom error messages
const schema = z.object({
email: z.string().email('Please enter a valid email address'),
});Related Skills
tanstack-query-advanced- Combine form mutations with TanStack Queryzustand-patterns- Form wizard state with multi-step persistenceinput-validation- Server-side validation and sanitizationaccessibility-specialist- WCAG compliance for forms
Capability Details
zod-validation
Keywords: zod, schema, validation, refine, transform, parse Solves: Type-safe validation with automatic TypeScript inference
field-arrays
Keywords: useFieldArray, dynamic, add, remove, append, swap, move Solves: Dynamic forms with add/remove items like invoices, surveys
server-actions
Keywords: useActionState, Server Actions, 'use server', formData Solves: React 19 progressive enhancement with server-side validation
multi-step-wizard
Keywords: wizard, steps, trigger, FormProvider, partial validation Solves: Complex multi-page forms with step-by-step validation
async-validation
Keywords: async, refine, debounce, username, availability Solves: Server-side validation during input (e.g., username availability)
file-upload
Keywords: FileList, File, upload, preview, drag-drop, validation Solves: File input validation with size, type, and preview handling
References
references/validation-patterns.md- Advanced Zod patternsscripts/form-template.tsx- Production form templatechecklists/form-checklist.md- Implementation checklistexamples/form-examples.md- Real-world form examples
Form State Patterns Implementation Checklist
Comprehensive checklist for production-ready forms with React Hook Form v7 + Zod.
Schema & Validation
Zod Schema Setup
- [ ] Zod schema defined with all fields
- [ ] Custom error messages for all validations
- [ ] Type inferred from schema:
type FormData = z.infer<typeof schema> - [ ] Schema exported for reuse (server validation, API types)
Field Validation
- [ ] String fields:
.min(),.max(),.email(),.url()as needed - [ ] Number fields:
.min(),.max(),.positive(),.int()as needed - [ ] Date fields:
.date()orz.coerce.date()for date strings - [ ] Optional fields:
.optional()or.nullable()as appropriate - [ ] Array fields:
.array().min(1)for required arrays - [ ] Enum fields:
z.enum()for controlled sets
Cross-Field Validation
- [ ]
.refine()used for dependent field validation - [ ]
pathspecified in refine for correct error placement - [ ]
.superRefine()for complex multi-field logic
// ✅ CORRECT: Password confirmation with path
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'], // Error appears on confirmPassword
});Async Validation
- [ ] Async refine used sparingly (not on every keystroke)
- [ ] Debounce or
mode: 'onBlur'for async validation - [ ] Loading indicator during async validation
- [ ] Error handling for network failures
Form Hook Setup
useForm Configuration
- [ ]
zodResolverconfigured:resolver: zodResolver(schema) - [ ]
defaultValuesprovided for ALL fields - [ ]
modeset appropriately: onBlur- Large forms, async validation (recommended)onChange- Real-time validation (use sparingly)onSubmit- Minimal validation UXonTouched- Validate after first blur, then onChange- [ ]
reValidateModematches UX needs
// ✅ CORRECT: Complete setup
const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
email: '',
password: '',
rememberMe: false,
},
mode: 'onBlur',
});Form Element
- [ ]
noValidateattribute on<form>(Zod handles validation) - [ ]
onSubmit={handleSubmit(onSubmit)}properly attached - [ ] Form has proper structure (fieldsets for groups)
Field Registration
register() Usage
- [ ] All inputs use
{...register('fieldName')} - [ ] Number inputs:
{ valueAsNumber: true } - [ ] Date inputs:
{ valueAsDate: true }when appropriate - [ ] Checkbox:
type="checkbox"with boolean defaultValue
Controller for Third-Party Components
- [ ]
Controllerused for non-native inputs (date pickers, rich text, etc.) - [ ]
field.value,field.onChange,field.onBlurproperly passed - [ ]
fieldState.errorused for error display
// ✅ CORRECT: Controller integration
<Controller
name="eventDate"
control={control}
render={({ field, fieldState }) => (
<DatePicker
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
error={fieldState.error?.message}
/>
)}
/>Accessibility
Labels & Inputs
- [ ] All inputs have associated
<label>elements - [ ] Labels use
htmlFormatching inputid - [ ] OR inputs wrapped in
<label>elements - [ ] Placeholder is NOT a substitute for labels
Error States
- [ ] Invalid inputs have
aria-invalid="true" - [ ] Error messages have
role="alert" - [ ]
aria-describedbylinks input to error message - [ ] Error messages have unique IDs
// ✅ CORRECT: Accessible input with error
<label htmlFor="email">Email</label>
<input
id="email"
{...register('email')}
aria-invalid={!!errors.email}
aria-describedby={errors.email ? 'email-error' : undefined}
/>
{errors.email && (
<p id="email-error" role="alert">
{errors.email.message}
</p>
)}Focus Management
- [ ] Focus moves to first error on submit failure
- [ ] Focus trapped in modal forms
- [ ] Focus returns to trigger after modal close
- [ ] Skip links for long forms
Screen Readers
- [ ] Required fields marked with
aria-required="true" - [ ] Form sections use
fieldsetandlegend - [ ] Progress announced in multi-step forms
- [ ] Submit result announced (success/failure)
User Experience
Loading States
- [ ] Submit button shows loading indicator during
isSubmitting - [ ] Button disabled during submission
- [ ] Form inputs disabled during submission (optional)
- [ ] Clear loading state on error
Feedback
- [ ] Success message shown after submit
- [ ] Error summary at top for multiple errors (optional)
- [ ] Individual field errors near fields
- [ ] Toast/notification for async operations
Form Reset
- [ ] Form reset after successful submit (if appropriate)
- [ ] Confirm before leaving with unsaved changes
- [ ] Clear server errors on new submit attempt
Field UX
- [ ] Autofocus on first field
- [ ] Tab order is logical
- [ ] Password visibility toggle
- [ ] Input masks for phone/credit card (optional)
Performance
Re-render Optimization
- [ ]
mode: 'onBlur'for large forms - [ ]
Controlleronly for components that need it - [ ]
useWatchfor specific field subscriptions - [ ]
useFormContextin deeply nested components
Avoid These (Performance Killers)
- [ ] NOT watching entire form:
watch()without arguments - [ ] NOT re-rendering on every keystroke unnecessarily
- [ ] NOT using controlled inputs when uncontrolled works
- [ ] NOT creating new functions in render (use useCallback)
// ❌ BAD: Watches entire form, re-renders on any change
const allValues = watch();
// ✅ GOOD: Watch specific fields
const email = watch('email');
// ✅ BETTER: useWatch for isolated re-renders
const email = useWatch({ control, name: 'email' });Field Arrays
useFieldArray Setup
- [ ]
useFieldArrayused for dynamic field lists - [ ]
controlpassed from useForm - [ ]
namematches schema array field
Rendering
- [ ]
key={field.id}used (NOT index!) - [ ]
fields.map()for iteration - [ ] Index used for register path:
items.${index}.name
// ✅ CORRECT: Field array with proper key
{fields.map((field, index) => (
<div key={field.id}> {/* Use field.id, NOT index */}
<input {...register(`items.${index}.name`)} />
<button type="button" onClick={() => remove(index)}>Remove</button>
</div>
))}Operations
- [ ]
append()adds new items - [ ]
remove()removes by index - [ ]
move()for reordering (drag & drop) - [ ]
swap()for adjacent swaps - [ ]
insert()for specific position - [ ]
prepend()for adding at start
Validation
- [ ] Min/max array length in schema
- [ ] Individual item validation
- [ ] Error display per item
Server Actions (React 19 / Next.js)
Action Setup
- [ ]
'use server'directive at top of file - [ ] Action receives
FormDataor validated data - [ ] Zod validation on server side
- [ ] Return type includes errors and success
// ✅ CORRECT: Server action with validation
'use server';
export async function submitForm(formData: FormData) {
const result = schema.safeParse({
email: formData.get('email'),
message: formData.get('message'),
});
if (!result.success) {
return { errors: result.error.flatten().fieldErrors };
}
await saveToDatabase(result.data);
return { success: true };
}useActionState Usage
- [ ]
useActionStatehook used (React 19) - [ ] Initial state provided
- [ ]
isPendingused for loading state - [ ] Server errors displayed in UI
// ✅ CORRECT: useActionState integration
'use client';
function ContactForm() {
const [state, formAction, isPending] = useActionState(submitForm, null);
return (
<form action={formAction}>
<input name="email" />
{state?.errors?.email && <span>{state.errors.email[0]}</span>}
<button disabled={isPending}>
{isPending ? 'Sending...' : 'Send'}
</button>
</form>
);
}Multi-Step Forms (Wizards)
State Management
- [ ]
FormProviderwraps entire wizard - [ ] Single useForm instance for all steps
- [ ] Step state managed separately from form
Step Validation
- [ ]
trigger(stepFields)validates current step before proceeding - [ ] Only proceed if validation passes
- [ ] Show errors on current step
Navigation
- [ ] Back button doesn't lose data
- [ ] Progress indicator shows current step
- [ ] Optional: step completion indicators
- [ ] Optional: jump to completed steps
// ✅ CORRECT: Step validation before proceeding
const nextStep = async () => {
const isValid = await methods.trigger(stepFields[currentStep]);
if (isValid) setCurrentStep((s) => s + 1);
};Persistence (Optional)
- [ ] Save progress to localStorage/sessionStorage
- [ ] Restore progress on page reload
- [ ] Clear saved data on successful submit
File Uploads
Validation
- [ ] File type validation with
refine - [ ] File size validation
- [ ] File count validation for multiple uploads
// ✅ CORRECT: File validation schema
const fileSchema = z
.instanceof(FileList)
.refine((files) => files.length === 1, 'File required')
.refine((files) => files[0]?.size <= 5_000_000, 'Max 5MB')
.refine(
(files) => ['image/jpeg', 'image/png'].includes(files[0]?.type),
'Only JPEG or PNG'
);UX
- [ ] Preview for images
- [ ] File name display
- [ ] Remove/clear button
- [ ] Progress indicator for large uploads
- [ ] Drag and drop support (optional)
Error Handling
Client-Side
- [ ] Validation errors displayed per field
- [ ] Form-level errors displayed appropriately
- [ ] Errors cleared on successful submit
Server-Side
- [ ] Server errors mapped to fields when possible
- [ ] Generic errors shown in toast/banner
- [ ] Network errors handled gracefully
- [ ] Rate limiting errors shown appropriately
setError Usage
- [ ]
setErrorused for server-returned errors - [ ]
clearErrorscalled on retry/resubmit - [ ] Error type specified:
'server'or custom
// ✅ CORRECT: Server error handling
const onSubmit = async (data: FormData) => {
try {
clearErrors(); // Clear previous errors
const result = await submitToServer(data);
if (!result.success && result.fieldErrors) {
Object.entries(result.fieldErrors).forEach(([field, message]) => {
setError(field as keyof FormData, { type: 'server', message });
});
}
} catch (error) {
setError('root', { type: 'server', message: 'Network error' });
}
};Testing Checklist
Unit Tests
- [ ] Schema validation tested with valid data
- [ ] Schema validation tested with invalid data
- [ ] Custom error messages verified
- [ ] Transform/preprocess logic tested
Component Tests
- [ ] Form renders correctly
- [ ] Validation errors display
- [ ] Submit triggers callback with correct data
- [ ] Loading states work correctly
- [ ] Field arrays add/remove correctly
Integration Tests
- [ ] End-to-end form submission
- [ ] Server error handling
- [ ] Multi-step navigation
- [ ] Accessibility (a11y) automated checks
Security Checklist
- [ ] Server-side validation (never trust client)
- [ ] CSRF protection (Next.js handles automatically)
- [ ] Rate limiting on form submissions
- [ ] Sanitize inputs before database storage
- [ ] No sensitive data in error messages
- [ ] Honeypot field for spam prevention (optional)
Form State Examples
Real-world form implementations with React Hook Form v7 + Zod.
---
1. User Registration with Password Strength
Complete registration form with password strength indicator and confirmation.
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useState, useCallback } from 'react';
// Password strength calculator
function getPasswordStrength(password: string): {
score: number;
label: string;
color: string;
} {
let score = 0;
if (password.length >= 8) score++;
if (password.length >= 12) score++;
if (/[a-z]/.test(password) && /[A-Z]/.test(password)) score++;
if (/\d/.test(password)) score++;
if (/[^a-zA-Z0-9]/.test(password)) score++;
const levels = [
{ label: 'Very Weak', color: 'bg-red-500' },
{ label: 'Weak', color: 'bg-orange-500' },
{ label: 'Fair', color: 'bg-yellow-500' },
{ label: 'Strong', color: 'bg-lime-500' },
{ label: 'Very Strong', color: 'bg-green-500' },
];
return { score, ...levels[Math.min(score, 4)] };
}
// Schema with cross-field validation
const registrationSchema = z
.object({
email: z
.string()
.min(1, 'Email is required')
.email('Please enter a valid email address'),
username: z
.string()
.min(3, 'Username must be at least 3 characters')
.max(20, 'Username must be at most 20 characters')
.regex(/^[a-zA-Z0-9_]+$/, 'Only letters, numbers, and underscores'),
password: z
.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Password must contain an uppercase letter')
.regex(/[a-z]/, 'Password must contain a lowercase letter')
.regex(/[0-9]/, 'Password must contain a number'),
confirmPassword: z.string().min(1, 'Please confirm your password'),
acceptTerms: z.literal(true, {
errorMap: () => ({ message: 'You must accept the terms' }),
}),
})
.refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ['confirmPassword'],
});
type RegistrationForm = z.infer<typeof registrationSchema>;
export function RegistrationForm() {
const [showPassword, setShowPassword] = useState(false);
const {
register,
handleSubmit,
watch,
formState: { errors, isSubmitting },
} = useForm<RegistrationForm>({
resolver: zodResolver(registrationSchema),
defaultValues: {
email: '',
username: '',
password: '',
confirmPassword: '',
acceptTerms: false as unknown as true, // Type workaround for z.literal(true)
},
mode: 'onBlur',
});
const password = watch('password');
const strength = password ? getPasswordStrength(password) : null;
const onSubmit = async (data: RegistrationForm) => {
await api.register(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate className="space-y-4">
{/* Email */}
<div>
<label htmlFor="email" className="block text-sm font-medium">
Email
</label>
<input
id="email"
type="email"
{...register('email')}
aria-invalid={!!errors.email}
aria-describedby={errors.email ? 'email-error' : undefined}
className="mt-1 block w-full rounded-md border px-3 py-2"
/>
{errors.email && (
<p id="email-error" role="alert" className="mt-1 text-sm text-red-600">
{errors.email.message}
</p>
)}
</div>
{/* Username */}
<div>
<label htmlFor="username" className="block text-sm font-medium">
Username
</label>
<input
id="username"
{...register('username')}
aria-invalid={!!errors.username}
aria-describedby={errors.username ? 'username-error' : undefined}
className="mt-1 block w-full rounded-md border px-3 py-2"
/>
{errors.username && (
<p id="username-error" role="alert" className="mt-1 text-sm text-red-600">
{errors.username.message}
</p>
)}
</div>
{/* Password with strength indicator */}
<div>
<label htmlFor="password" className="block text-sm font-medium">
Password
</label>
<div className="relative">
<input
id="password"
type={showPassword ? 'text' : 'password'}
{...register('password')}
aria-invalid={!!errors.password}
aria-describedby={errors.password ? 'password-error' : 'password-strength'}
className="mt-1 block w-full rounded-md border px-3 py-2 pr-10"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2"
aria-label={showPassword ? 'Hide password' : 'Show password'}
>
{showPassword ? '👁️' : '👁️🗨️'}
</button>
</div>
{strength && (
<div id="password-strength" className="mt-2">
<div className="flex gap-1">
{[...Array(5)].map((_, i) => (
<div
key={i}
className={`h-1 flex-1 rounded ${
i < strength.score ? strength.color : 'bg-gray-200'
}`}
/>
))}
</div>
<p className="mt-1 text-sm text-gray-600">{strength.label}</p>
</div>
)}
{errors.password && (
<p id="password-error" role="alert" className="mt-1 text-sm text-red-600">
{errors.password.message}
</p>
)}
</div>
{/* Confirm Password */}
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium">
Confirm Password
</label>
<input
id="confirmPassword"
type="password"
{...register('confirmPassword')}
aria-invalid={!!errors.confirmPassword}
aria-describedby={errors.confirmPassword ? 'confirm-error' : undefined}
className="mt-1 block w-full rounded-md border px-3 py-2"
/>
{errors.confirmPassword && (
<p id="confirm-error" role="alert" className="mt-1 text-sm text-red-600">
{errors.confirmPassword.message}
</p>
)}
</div>
{/* Terms */}
<div className="flex items-start">
<input
id="acceptTerms"
type="checkbox"
{...register('acceptTerms')}
aria-invalid={!!errors.acceptTerms}
className="mt-1 h-4 w-4"
/>
<label htmlFor="acceptTerms" className="ml-2 text-sm">
I accept the <a href="/terms" className="underline">terms and conditions</a>
</label>
</div>
{errors.acceptTerms && (
<p role="alert" className="text-sm text-red-600">
{errors.acceptTerms.message}
</p>
)}
<button
type="submit"
disabled={isSubmitting}
className="w-full rounded-md bg-blue-600 px-4 py-2 text-white disabled:opacity-50"
>
{isSubmitting ? 'Creating account...' : 'Create Account'}
</button>
</form>
);
}---
2. Multi-Step Checkout Wizard
E-commerce checkout with shipping, payment, and review steps.
import { useForm, FormProvider, useFormContext } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useState } from 'react';
// Step schemas
const shippingSchema = z.object({
firstName: z.string().min(1, 'First name is required'),
lastName: z.string().min(1, 'Last name is required'),
address: z.string().min(5, 'Address must be at least 5 characters'),
city: z.string().min(2, 'City is required'),
state: z.string().min(2, 'State is required'),
zipCode: z.string().regex(/^\d{5}(-\d{4})?$/, 'Invalid ZIP code'),
phone: z.string().regex(/^\+?[\d\s-()]+$/, 'Invalid phone number'),
});
const paymentSchema = z.object({
cardNumber: z
.string()
.regex(/^\d{16}$/, 'Card number must be 16 digits'),
cardName: z.string().min(1, 'Name on card is required'),
expiryDate: z
.string()
.regex(/^(0[1-9]|1[0-2])\/\d{2}$/, 'Format: MM/YY'),
cvv: z.string().regex(/^\d{3,4}$/, 'CVV must be 3-4 digits'),
});
// Combined schema
const checkoutSchema = z.object({
shipping: shippingSchema,
payment: paymentSchema,
savePaymentMethod: z.boolean().optional(),
notes: z.string().optional(),
});
type CheckoutForm = z.infer<typeof checkoutSchema>;
const steps = ['shipping', 'payment', 'review'] as const;
type Step = (typeof steps)[number];
// Step fields for validation
const stepFields: Record<Step, (keyof CheckoutForm)[]> = {
shipping: ['shipping'],
payment: ['payment'],
review: [],
};
// Shipping Step Component
function ShippingStep() {
const {
register,
formState: { errors },
} = useFormContext<CheckoutForm>();
return (
<div className="space-y-4">
<h2 className="text-xl font-semibold">Shipping Information</h2>
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="firstName" className="block text-sm font-medium">
First Name
</label>
<input
id="firstName"
{...register('shipping.firstName')}
aria-invalid={!!errors.shipping?.firstName}
className="mt-1 block w-full rounded-md border px-3 py-2"
/>
{errors.shipping?.firstName && (
<p role="alert" className="mt-1 text-sm text-red-600">
{errors.shipping.firstName.message}
</p>
)}
</div>
<div>
<label htmlFor="lastName" className="block text-sm font-medium">
Last Name
</label>
<input
id="lastName"
{...register('shipping.lastName')}
aria-invalid={!!errors.shipping?.lastName}
className="mt-1 block w-full rounded-md border px-3 py-2"
/>
{errors.shipping?.lastName && (
<p role="alert" className="mt-1 text-sm text-red-600">
{errors.shipping.lastName.message}
</p>
)}
</div>
</div>
<div>
<label htmlFor="address" className="block text-sm font-medium">
Address
</label>
<input
id="address"
{...register('shipping.address')}
aria-invalid={!!errors.shipping?.address}
className="mt-1 block w-full rounded-md border px-3 py-2"
/>
{errors.shipping?.address && (
<p role="alert" className="mt-1 text-sm text-red-600">
{errors.shipping.address.message}
</p>
)}
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<label htmlFor="city" className="block text-sm font-medium">
City
</label>
<input
id="city"
{...register('shipping.city')}
className="mt-1 block w-full rounded-md border px-3 py-2"
/>
</div>
<div>
<label htmlFor="state" className="block text-sm font-medium">
State
</label>
<input
id="state"
{...register('shipping.state')}
className="mt-1 block w-full rounded-md border px-3 py-2"
/>
</div>
<div>
<label htmlFor="zipCode" className="block text-sm font-medium">
ZIP Code
</label>
<input
id="zipCode"
{...register('shipping.zipCode')}
className="mt-1 block w-full rounded-md border px-3 py-2"
/>
</div>
</div>
<div>
<label htmlFor="phone" className="block text-sm font-medium">
Phone Number
</label>
<input
id="phone"
type="tel"
{...register('shipping.phone')}
className="mt-1 block w-full rounded-md border px-3 py-2"
/>
</div>
</div>
);
}
// Payment Step Component
function PaymentStep() {
const {
register,
formState: { errors },
} = useFormContext<CheckoutForm>();
return (
<div className="space-y-4">
<h2 className="text-xl font-semibold">Payment Information</h2>
<div>
<label htmlFor="cardNumber" className="block text-sm font-medium">
Card Number
</label>
<input
id="cardNumber"
{...register('payment.cardNumber')}
placeholder="1234 5678 9012 3456"
maxLength={16}
className="mt-1 block w-full rounded-md border px-3 py-2"
/>
{errors.payment?.cardNumber && (
<p role="alert" className="mt-1 text-sm text-red-600">
{errors.payment.cardNumber.message}
</p>
)}
</div>
<div>
<label htmlFor="cardName" className="block text-sm font-medium">
Name on Card
</label>
<input
id="cardName"
{...register('payment.cardName')}
className="mt-1 block w-full rounded-md border px-3 py-2"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="expiryDate" className="block text-sm font-medium">
Expiry Date
</label>
<input
id="expiryDate"
{...register('payment.expiryDate')}
placeholder="MM/YY"
maxLength={5}
className="mt-1 block w-full rounded-md border px-3 py-2"
/>
</div>
<div>
<label htmlFor="cvv" className="block text-sm font-medium">
CVV
</label>
<input
id="cvv"
type="password"
{...register('payment.cvv')}
maxLength={4}
className="mt-1 block w-full rounded-md border px-3 py-2"
/>
</div>
</div>
<div className="flex items-center">
<input
id="savePayment"
type="checkbox"
{...register('savePaymentMethod')}
className="h-4 w-4"
/>
<label htmlFor="savePayment" className="ml-2 text-sm">
Save payment method for future purchases
</label>
</div>
</div>
);
}
// Review Step Component
function ReviewStep() {
const { watch } = useFormContext<CheckoutForm>();
const data = watch();
return (
<div className="space-y-6">
<h2 className="text-xl font-semibold">Review Order</h2>
<div className="rounded-lg border p-4">
<h3 className="font-medium">Shipping Address</h3>
<p className="mt-2 text-gray-600">
{data.shipping.firstName} {data.shipping.lastName}
<br />
{data.shipping.address}
<br />
{data.shipping.city}, {data.shipping.state} {data.shipping.zipCode}
<br />
{data.shipping.phone}
</p>
</div>
<div className="rounded-lg border p-4">
<h3 className="font-medium">Payment Method</h3>
<p className="mt-2 text-gray-600">
Card ending in {data.payment.cardNumber.slice(-4)}
<br />
Expires {data.payment.expiryDate}
</p>
</div>
<div>
<label htmlFor="notes" className="block text-sm font-medium">
Order Notes (optional)
</label>
<textarea
id="notes"
{...useFormContext<CheckoutForm>().register('notes')}
rows={3}
className="mt-1 block w-full rounded-md border px-3 py-2"
/>
</div>
</div>
);
}
// Step Indicator Component
function StepIndicator({
current,
steps,
}: {
current: number;
steps: readonly string[];
}) {
return (
<div className="flex items-center justify-between">
{steps.map((step, index) => (
<div key={step} className="flex items-center">
<div
className={`flex h-8 w-8 items-center justify-center rounded-full ${
index <= current
? 'bg-blue-600 text-white'
: 'bg-gray-200 text-gray-600'
}`}
>
{index < current ? '✓' : index + 1}
</div>
<span className="ml-2 capitalize">{step}</span>
{index < steps.length - 1 && (
<div className="mx-4 h-0.5 w-16 bg-gray-200" />
)}
</div>
))}
</div>
);
}
// Main Checkout Form
export function CheckoutWizard() {
const [currentStep, setCurrentStep] = useState(0);
const methods = useForm<CheckoutForm>({
resolver: zodResolver(checkoutSchema),
defaultValues: {
shipping: {
firstName: '',
lastName: '',
address: '',
city: '',
state: '',
zipCode: '',
phone: '',
},
payment: {
cardNumber: '',
cardName: '',
expiryDate: '',
cvv: '',
},
savePaymentMethod: false,
notes: '',
},
mode: 'onBlur',
});
const nextStep = async () => {
const fields = stepFields[steps[currentStep]];
const isValid = await methods.trigger(fields as any);
if (isValid) {
setCurrentStep((s) => Math.min(s + 1, steps.length - 1));
}
};
const prevStep = () => {
setCurrentStep((s) => Math.max(s - 1, 0));
};
const onSubmit = async (data: CheckoutForm) => {
await api.placeOrder(data);
};
return (
<FormProvider {...methods}>
<div className="mx-auto max-w-2xl p-6">
<StepIndicator current={currentStep} steps={steps} />
<form onSubmit={methods.handleSubmit(onSubmit)} className="mt-8">
{currentStep === 0 && <ShippingStep />}
{currentStep === 1 && <PaymentStep />}
{currentStep === 2 && <ReviewStep />}
<div className="mt-8 flex justify-between">
{currentStep > 0 && (
<button
type="button"
onClick={prevStep}
className="rounded-md border px-6 py-2"
>
Back
</button>
)}
<div className="ml-auto">
{currentStep < steps.length - 1 ? (
<button
type="button"
onClick={nextStep}
className="rounded-md bg-blue-600 px-6 py-2 text-white"
>
Continue
</button>
) : (
<button
type="submit"
disabled={methods.formState.isSubmitting}
className="rounded-md bg-green-600 px-6 py-2 text-white disabled:opacity-50"
>
{methods.formState.isSubmitting
? 'Processing...'
: 'Place Order'}
</button>
)}
</div>
</div>
</form>
</div>
</FormProvider>
);
}---
3. Dynamic Invoice Builder
Invoice form with dynamic line items and automatic calculations.
import { useForm, useFieldArray, useWatch } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useMemo } from 'react';
const lineItemSchema = z.object({
description: z.string().min(1, 'Description is required'),
quantity: z.number().min(1, 'Quantity must be at least 1'),
unitPrice: z.number().min(0, 'Price must be positive'),
taxRate: z.number().min(0).max(100).optional(),
});
const invoiceSchema = z.object({
invoiceNumber: z.string().min(1, 'Invoice number is required'),
client: z.object({
name: z.string().min(1, 'Client name is required'),
email: z.string().email('Invalid email'),
address: z.string().optional(),
}),
issueDate: z.string().min(1, 'Issue date is required'),
dueDate: z.string().min(1, 'Due date is required'),
items: z.array(lineItemSchema).min(1, 'At least one item required'),
notes: z.string().optional(),
discount: z.number().min(0).max(100).optional(),
});
type InvoiceForm = z.infer<typeof invoiceSchema>;
type LineItem = z.infer<typeof lineItemSchema>;
// Totals Calculator Component
function InvoiceTotals({ control }: { control: any }) {
const items = useWatch({ control, name: 'items' }) as LineItem[];
const discount = useWatch({ control, name: 'discount' }) as number | undefined;
const totals = useMemo(() => {
const subtotal = items.reduce(
(sum, item) => sum + item.quantity * item.unitPrice,
0
);
const taxTotal = items.reduce((sum, item) => {
const lineTotal = item.quantity * item.unitPrice;
return sum + lineTotal * ((item.taxRate ?? 0) / 100);
}, 0);
const discountAmount = subtotal * ((discount ?? 0) / 100);
const total = subtotal + taxTotal - discountAmount;
return { subtotal, taxTotal, discountAmount, total };
}, [items, discount]);
return (
<div className="rounded-lg bg-gray-50 p-4">
<div className="space-y-2 text-right">
<div className="flex justify-between">
<span>Subtotal:</span>
<span>${totals.subtotal.toFixed(2)}</span>
</div>
<div className="flex justify-between">
<span>Tax:</span>
<span>${totals.taxTotal.toFixed(2)}</span>
</div>
{totals.discountAmount > 0 && (
<div className="flex justify-between text-green-600">
<span>Discount:</span>
<span>-${totals.discountAmount.toFixed(2)}</span>
</div>
)}
<div className="flex justify-between border-t pt-2 text-lg font-bold">
<span>Total:</span>
<span>${totals.total.toFixed(2)}</span>
</div>
</div>
</div>
);
}
// Line Item Row Component
function LineItemRow({
index,
register,
remove,
errors,
}: {
index: number;
register: any;
remove: (index: number) => void;
errors: any;
}) {
return (
<div className="grid grid-cols-12 gap-2 items-start">
<div className="col-span-4">
<input
{...register(`items.${index}.description`)}
placeholder="Description"
aria-invalid={!!errors?.items?.[index]?.description}
className="w-full rounded-md border px-3 py-2"
/>
{errors?.items?.[index]?.description && (
<p role="alert" className="text-xs text-red-600">
{errors.items[index].description.message}
</p>
)}
</div>
<div className="col-span-2">
<input
type="number"
{...register(`items.${index}.quantity`, { valueAsNumber: true })}
placeholder="Qty"
min={1}
className="w-full rounded-md border px-3 py-2"
/>
</div>
<div className="col-span-2">
<input
type="number"
step="0.01"
{...register(`items.${index}.unitPrice`, { valueAsNumber: true })}
placeholder="Price"
min={0}
className="w-full rounded-md border px-3 py-2"
/>
</div>
<div className="col-span-2">
<input
type="number"
{...register(`items.${index}.taxRate`, { valueAsNumber: true })}
placeholder="Tax %"
min={0}
max={100}
className="w-full rounded-md border px-3 py-2"
/>
</div>
<div className="col-span-2">
<button
type="button"
onClick={() => remove(index)}
className="rounded-md bg-red-100 px-3 py-2 text-red-600 hover:bg-red-200"
aria-label={`Remove item ${index + 1}`}
>
Remove
</button>
</div>
</div>
);
}
// Main Invoice Form
export function InvoiceBuilder() {
const {
register,
control,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<InvoiceForm>({
resolver: zodResolver(invoiceSchema),
defaultValues: {
invoiceNumber: `INV-${Date.now()}`,
client: { name: '', email: '', address: '' },
issueDate: new Date().toISOString().split('T')[0],
dueDate: '',
items: [{ description: '', quantity: 1, unitPrice: 0, taxRate: 0 }],
notes: '',
discount: 0,
},
mode: 'onBlur',
});
const { fields, append, remove, move } = useFieldArray({
control,
name: 'items',
});
const onSubmit = async (data: InvoiceForm) => {
await api.createInvoice(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate className="space-y-6">
{/* Header Info */}
<div className="grid grid-cols-2 gap-6">
<div>
<h2 className="text-lg font-semibold mb-4">Invoice Details</h2>
<div className="space-y-4">
<div>
<label htmlFor="invoiceNumber" className="block text-sm font-medium">
Invoice Number
</label>
<input
id="invoiceNumber"
{...register('invoiceNumber')}
className="mt-1 block w-full rounded-md border px-3 py-2"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="issueDate" className="block text-sm font-medium">
Issue Date
</label>
<input
id="issueDate"
type="date"
{...register('issueDate')}
className="mt-1 block w-full rounded-md border px-3 py-2"
/>
</div>
<div>
<label htmlFor="dueDate" className="block text-sm font-medium">
Due Date
</label>
<input
id="dueDate"
type="date"
{...register('dueDate')}
className="mt-1 block w-full rounded-md border px-3 py-2"
/>
</div>
</div>
</div>
</div>
<div>
<h2 className="text-lg font-semibold mb-4">Client Information</h2>
<div className="space-y-4">
<div>
<label htmlFor="clientName" className="block text-sm font-medium">
Client Name
</label>
<input
id="clientName"
{...register('client.name')}
aria-invalid={!!errors.client?.name}
className="mt-1 block w-full rounded-md border px-3 py-2"
/>
{errors.client?.name && (
<p role="alert" className="mt-1 text-sm text-red-600">
{errors.client.name.message}
</p>
)}
</div>
<div>
<label htmlFor="clientEmail" className="block text-sm font-medium">
Client Email
</label>
<input
id="clientEmail"
type="email"
{...register('client.email')}
className="mt-1 block w-full rounded-md border px-3 py-2"
/>
</div>
<div>
<label htmlFor="clientAddress" className="block text-sm font-medium">
Address (optional)
</label>
<textarea
id="clientAddress"
{...register('client.address')}
rows={2}
className="mt-1 block w-full rounded-md border px-3 py-2"
/>
</div>
</div>
</div>
</div>
{/* Line Items */}
<div>
<h2 className="text-lg font-semibold mb-4">Line Items</h2>
{/* Column Headers */}
<div className="grid grid-cols-12 gap-2 mb-2 text-sm font-medium text-gray-600">
<div className="col-span-4">Description</div>
<div className="col-span-2">Quantity</div>
<div className="col-span-2">Unit Price</div>
<div className="col-span-2">Tax Rate (%)</div>
<div className="col-span-2">Actions</div>
</div>
<div className="space-y-3">
{fields.map((field, index) => (
<LineItemRow
key={field.id} // Use field.id, NOT index!
index={index}
register={register}
remove={remove}
errors={errors}
/>
))}
</div>
{errors.items?.message && (
<p role="alert" className="mt-2 text-sm text-red-600">
{errors.items.message}
</p>
)}
<button
type="button"
onClick={() =>
append({ description: '', quantity: 1, unitPrice: 0, taxRate: 0 })
}
className="mt-4 rounded-md border border-blue-600 px-4 py-2 text-blue-600 hover:bg-blue-50"
>
+ Add Line Item
</button>
</div>
{/* Discount and Notes */}
<div className="grid grid-cols-2 gap-6">
<div>
<label htmlFor="notes" className="block text-sm font-medium">
Notes (optional)
</label>
<textarea
id="notes"
{...register('notes')}
rows={3}
placeholder="Payment terms, thank you message, etc."
className="mt-1 block w-full rounded-md border px-3 py-2"
/>
</div>
<div>
<label htmlFor="discount" className="block text-sm font-medium">
Discount (%)
</label>
<input
id="discount"
type="number"
{...register('discount', { valueAsNumber: true })}
min={0}
max={100}
className="mt-1 block w-full rounded-md border px-3 py-2"
/>
</div>
</div>
{/* Totals */}
<InvoiceTotals control={control} />
{/* Submit */}
<div className="flex justify-end">
<button
type="submit"
disabled={isSubmitting}
className="rounded-md bg-blue-600 px-6 py-2 text-white disabled:opacity-50"
>
{isSubmitting ? 'Creating...' : 'Create Invoice'}
</button>
</div>
</form>
);
}---
4. Contact Form with Server Actions (React 19)
Next.js Server Action form with progressive enhancement.
// app/contact/actions.ts
'use server';
import { z } from 'zod';
import { revalidatePath } from 'next/cache';
const contactSchema = z.object({
name: z.string().min(2, 'Name must be at least 2 characters'),
email: z.string().email('Please enter a valid email'),
subject: z.enum(['general', 'support', 'sales', 'partnership'], {
errorMap: () => ({ message: 'Please select a subject' }),
}),
message: z.string().min(10, 'Message must be at least 10 characters'),
urgent: z.boolean().optional(),
});
export type ContactFormState = {
success?: boolean;
errors?: {
name?: string[];
email?: string[];
subject?: string[];
message?: string[];
_form?: string[];
};
message?: string;
};
export async function submitContactForm(
prevState: ContactFormState | null,
formData: FormData
): Promise<ContactFormState> {
// Validate input
const result = contactSchema.safeParse({
name: formData.get('name'),
email: formData.get('email'),
subject: formData.get('subject'),
message: formData.get('message'),
urgent: formData.get('urgent') === 'on',
});
if (!result.success) {
return {
errors: result.error.flatten().fieldErrors,
};
}
// Simulate processing delay
await new Promise((resolve) => setTimeout(resolve, 1000));
// Check for rate limiting (example)
const rateLimitExceeded = false; // Replace with actual check
if (rateLimitExceeded) {
return {
errors: {
_form: ['Too many requests. Please try again later.'],
},
};
}
// Save to database
try {
await saveContactMessage(result.data);
revalidatePath('/contact');
return {
success: true,
message: 'Thank you! We will get back to you soon.',
};
} catch (error) {
return {
errors: {
_form: ['Something went wrong. Please try again.'],
},
};
}
}
// app/contact/page.tsx
'use client';
import { useActionState } from 'react';
import { submitContactForm, ContactFormState } from './actions';
export default function ContactPage() {
const [state, formAction, isPending] = useActionState<
ContactFormState | null,
FormData
>(submitContactForm, null);
if (state?.success) {
return (
<div className="mx-auto max-w-md p-6">
<div className="rounded-lg bg-green-50 p-6 text-center">
<h2 className="text-xl font-semibold text-green-800">
Message Sent!
</h2>
<p className="mt-2 text-green-600">{state.message}</p>
</div>
</div>
);
}
return (
<div className="mx-auto max-w-md p-6">
<h1 className="text-2xl font-bold mb-6">Contact Us</h1>
{/* Form-level errors */}
{state?.errors?._form && (
<div
role="alert"
className="mb-4 rounded-lg bg-red-50 p-4 text-red-600"
>
{state.errors._form.map((error, i) => (
<p key={i}>{error}</p>
))}
</div>
)}
<form action={formAction} className="space-y-4">
{/* Name */}
<div>
<label htmlFor="name" className="block text-sm font-medium">
Name
</label>
<input
id="name"
name="name"
type="text"
required
aria-invalid={!!state?.errors?.name}
aria-describedby={state?.errors?.name ? 'name-error' : undefined}
className="mt-1 block w-full rounded-md border px-3 py-2"
/>
{state?.errors?.name && (
<p id="name-error" role="alert" className="mt-1 text-sm text-red-600">
{state.errors.name[0]}
</p>
)}
</div>
{/* Email */}
<div>
<label htmlFor="email" className="block text-sm font-medium">
Email
</label>
<input
id="email"
name="email"
type="email"
required
aria-invalid={!!state?.errors?.email}
aria-describedby={state?.errors?.email ? 'email-error' : undefined}
className="mt-1 block w-full rounded-md border px-3 py-2"
/>
{state?.errors?.email && (
<p id="email-error" role="alert" className="mt-1 text-sm text-red-600">
{state.errors.email[0]}
</p>
)}
</div>
{/* Subject */}
<div>
<label htmlFor="subject" className="block text-sm font-medium">
Subject
</label>
<select
id="subject"
name="subject"
required
aria-invalid={!!state?.errors?.subject}
className="mt-1 block w-full rounded-md border px-3 py-2"
>
<option value="">Select a subject</option>
<option value="general">General Inquiry</option>
<option value="support">Technical Support</option>
<option value="sales">Sales</option>
<option value="partnership">Partnership</option>
</select>
{state?.errors?.subject && (
<p role="alert" className="mt-1 text-sm text-red-600">
{state.errors.subject[0]}
</p>
)}
</div>
{/* Message */}
<div>
<label htmlFor="message" className="block text-sm font-medium">
Message
</label>
<textarea
id="message"
name="message"
rows={4}
required
aria-invalid={!!state?.errors?.message}
aria-describedby={state?.errors?.message ? 'message-error' : undefined}
className="mt-1 block w-full rounded-md border px-3 py-2"
/>
{state?.errors?.message && (
<p id="message-error" role="alert" className="mt-1 text-sm text-red-600">
{state.errors.message[0]}
</p>
)}
</div>
{/* Urgent checkbox */}
<div className="flex items-center">
<input
id="urgent"
name="urgent"
type="checkbox"
className="h-4 w-4"
/>
<label htmlFor="urgent" className="ml-2 text-sm">
Mark as urgent
</label>
</div>
{/* Submit */}
<button
type="submit"
disabled={isPending}
className="w-full rounded-md bg-blue-600 px-4 py-2 text-white disabled:opacity-50"
>
{isPending ? 'Sending...' : 'Send Message'}
</button>
</form>
</div>
);
}---
5. Profile Settings with Async Username Validation
Form with async field validation and optimistic UI.
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useState, useCallback } from 'react';
import debounce from 'lodash.debounce';
// Async username check
async function checkUsernameAvailable(username: string): Promise<boolean> {
const response = await fetch(`/api/users/check-username?username=${username}`);
const { available } = await response.json();
return available;
}
const profileSchema = z.object({
username: z
.string()
.min(3, 'Username must be at least 3 characters')
.max(20, 'Username must be at most 20 characters')
.regex(/^[a-zA-Z0-9_]+$/, 'Only letters, numbers, and underscores'),
displayName: z.string().min(1, 'Display name is required'),
bio: z.string().max(160, 'Bio must be at most 160 characters').optional(),
website: z.string().url('Invalid URL').optional().or(z.literal('')),
avatar: z.instanceof(FileList).optional(),
});
type ProfileForm = z.infer<typeof profileSchema>;
export function ProfileSettingsForm({ initialData }: { initialData: Partial<ProfileForm> }) {
const [usernameStatus, setUsernameStatus] = useState<{
checking: boolean;
available: boolean | null;
error: string | null;
}>({ checking: false, available: null, error: null });
const {
register,
handleSubmit,
watch,
setError,
clearErrors,
formState: { errors, isSubmitting, isDirty },
} = useForm<ProfileForm>({
resolver: zodResolver(profileSchema),
defaultValues: {
username: initialData.username ?? '',
displayName: initialData.displayName ?? '',
bio: initialData.bio ?? '',
website: initialData.website ?? '',
},
mode: 'onBlur',
});
// Debounced username check
const checkUsername = useCallback(
debounce(async (username: string) => {
if (username.length < 3 || username === initialData.username) {
setUsernameStatus({ checking: false, available: null, error: null });
return;
}
setUsernameStatus({ checking: true, available: null, error: null });
try {
const available = await checkUsernameAvailable(username);
setUsernameStatus({ checking: false, available, error: null });
if (!available) {
setError('username', {
type: 'manual',
message: 'Username is already taken',
});
} else {
clearErrors('username');
}
} catch {
setUsernameStatus({
checking: false,
available: null,
error: 'Failed to check username',
});
}
}, 500),
[initialData.username, setError, clearErrors]
);
const username = watch('username');
// Check username when it changes
const handleUsernameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
checkUsername(e.target.value);
};
const onSubmit = async (data: ProfileForm) => {
// Don't submit if username check is pending or unavailable
if (usernameStatus.checking || usernameStatus.available === false) {
return;
}
await api.updateProfile(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate className="space-y-6">
{/* Username with async validation */}
<div>
<label htmlFor="username" className="block text-sm font-medium">
Username
</label>
<div className="relative">
<input
id="username"
{...register('username', {
onChange: handleUsernameChange,
})}
aria-invalid={!!errors.username || usernameStatus.available === false}
className="mt-1 block w-full rounded-md border px-3 py-2 pr-10"
/>
<div className="absolute right-3 top-1/2 -translate-y-1/2">
{usernameStatus.checking && (
<span className="text-gray-400">⏳</span>
)}
{!usernameStatus.checking && usernameStatus.available === true && (
<span className="text-green-500">✓</span>
)}
{!usernameStatus.checking && usernameStatus.available === false && (
<span className="text-red-500">✗</span>
)}
</div>
</div>
{errors.username && (
<p role="alert" className="mt-1 text-sm text-red-600">
{errors.username.message}
</p>
)}
{usernameStatus.available === true && !errors.username && (
<p className="mt-1 text-sm text-green-600">Username is available!</p>
)}
</div>
{/* Display Name */}
<div>
<label htmlFor="displayName" className="block text-sm font-medium">
Display Name
</label>
<input
id="displayName"
{...register('displayName')}
aria-invalid={!!errors.displayName}
className="mt-1 block w-full rounded-md border px-3 py-2"
/>
{errors.displayName && (
<p role="alert" className="mt-1 text-sm text-red-600">
{errors.displayName.message}
</p>
)}
</div>
{/* Bio with character count */}
<div>
<label htmlFor="bio" className="block text-sm font-medium">
Bio
</label>
<textarea
id="bio"
{...register('bio')}
rows={3}
maxLength={160}
className="mt-1 block w-full rounded-md border px-3 py-2"
/>
<div className="mt-1 flex justify-between text-sm">
{errors.bio && (
<p role="alert" className="text-red-600">
{errors.bio.message}
</p>
)}
<span className="ml-auto text-gray-500">
{watch('bio')?.length ?? 0}/160
</span>
</div>
</div>
{/* Website */}
<div>
<label htmlFor="website" className="block text-sm font-medium">
Website
</label>
<input
id="website"
type="url"
{...register('website')}
placeholder="https://example.com"
className="mt-1 block w-full rounded-md border px-3 py-2"
/>
{errors.website && (
<p role="alert" className="mt-1 text-sm text-red-600">
{errors.website.message}
</p>
)}
</div>
{/* Avatar Upload */}
<div>
<label htmlFor="avatar" className="block text-sm font-medium">
Avatar
</label>
<input
id="avatar"
type="file"
accept="image/*"
{...register('avatar')}
className="mt-1 block w-full"
/>
</div>
{/* Submit */}
<div className="flex gap-4">
<button
type="submit"
disabled={isSubmitting || !isDirty || usernameStatus.checking}
className="rounded-md bg-blue-600 px-4 py-2 text-white disabled:opacity-50"
>
{isSubmitting ? 'Saving...' : 'Save Changes'}
</button>
{isDirty && (
<p className="self-center text-sm text-amber-600">
You have unsaved changes
</p>
)}
</div>
</form>
);
}---
6. Survey Builder with Conditional Logic
Dynamic survey form with conditional fields and question types.
import { useForm, useFieldArray, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
// Question type definitions
const textQuestionSchema = z.object({
type: z.literal('text'),
question: z.string().min(1),
required: z.boolean(),
maxLength: z.number().optional(),
});
const choiceQuestionSchema = z.object({
type: z.literal('choice'),
question: z.string().min(1),
required: z.boolean(),
options: z.array(z.string().min(1)).min(2, 'At least 2 options required'),
multiple: z.boolean(),
});
const ratingQuestionSchema = z.object({
type: z.literal('rating'),
question: z.string().min(1),
required: z.boolean(),
scale: z.enum(['5', '10']),
});
const questionSchema = z.discriminatedUnion('type', [
textQuestionSchema,
choiceQuestionSchema,
ratingQuestionSchema,
]);
const surveySchema = z.object({
title: z.string().min(1, 'Survey title is required'),
description: z.string().optional(),
questions: z.array(questionSchema).min(1, 'Add at least one question'),
});
type SurveyForm = z.infer<typeof surveySchema>;
type Question = z.infer<typeof questionSchema>;
// Question Editor Component
function QuestionEditor({
index,
control,
register,
remove,
errors,
}: {
index: number;
control: any;
register: any;
remove: (index: number) => void;
errors: any;
}) {
const questionType = control._formValues.questions?.[index]?.type;
return (
<div className="rounded-lg border p-4 space-y-4">
<div className="flex justify-between items-start">
<span className="text-sm font-medium text-gray-500">
Question {index + 1}
</span>
<button
type="button"
onClick={() => remove(index)}
className="text-red-600 hover:text-red-800"
>
Remove
</button>
</div>
{/* Question Type Selector */}
<div>
<label className="block text-sm font-medium">Question Type</label>
<Controller
name={`questions.${index}.type`}
control={control}
render={({ field }) => (
<select
{...field}
className="mt-1 block w-full rounded-md border px-3 py-2"
>
<option value="text">Text Response</option>
<option value="choice">Multiple Choice</option>
<option value="rating">Rating Scale</option>
</select>
)}
/>
</div>
{/* Question Text */}
<div>
<label className="block text-sm font-medium">Question</label>
<input
{...register(`questions.${index}.question`)}
placeholder="Enter your question"
className="mt-1 block w-full rounded-md border px-3 py-2"
/>
{errors?.questions?.[index]?.question && (
<p role="alert" className="mt-1 text-sm text-red-600">
{errors.questions[index].question.message}
</p>
)}
</div>
{/* Required Checkbox */}
<div className="flex items-center">
<input
type="checkbox"
{...register(`questions.${index}.required`)}
className="h-4 w-4"
/>
<label className="ml-2 text-sm">Required</label>
</div>
{/* Type-specific options */}
{questionType === 'text' && (
<div>
<label className="block text-sm font-medium">Max Length (optional)</label>
<input
type="number"
{...register(`questions.${index}.maxLength`, { valueAsNumber: true })}
placeholder="No limit"
className="mt-1 block w-full rounded-md border px-3 py-2"
/>
</div>
)}
{questionType === 'choice' && (
<ChoiceOptionsEditor index={index} control={control} register={register} />
)}
{questionType === 'rating' && (
<div>
<label className="block text-sm font-medium">Scale</label>
<Controller
name={`questions.${index}.scale`}
control={control}
render={({ field }) => (
<select
{...field}
className="mt-1 block w-full rounded-md border px-3 py-2"
>
<option value="5">1-5 Stars</option>
<option value="10">1-10 Scale</option>
</select>
)}
/>
</div>
)}
</div>
);
}
// Choice Options Editor
function ChoiceOptionsEditor({
index,
control,
register,
}: {
index: number;
control: any;
register: any;
}) {
const { fields, append, remove } = useFieldArray({
control,
name: `questions.${index}.options`,
});
return (
<div className="space-y-2">
<label className="block text-sm font-medium">Options</label>
{fields.map((field, optionIndex) => (
<div key={field.id} className="flex gap-2">
<input
{...register(`questions.${index}.options.${optionIndex}`)}
placeholder={`Option ${optionIndex + 1}`}
className="flex-1 rounded-md border px-3 py-2"
/>
<button
type="button"
onClick={() => remove(optionIndex)}
className="px-2 text-red-600"
disabled={fields.length <= 2}
>
×
</button>
</div>
))}
<button
type="button"
onClick={() => append('')}
className="text-sm text-blue-600"
>
+ Add Option
</button>
{/* Multiple selection toggle */}
<div className="flex items-center mt-2">
<input
type="checkbox"
{...register(`questions.${index}.multiple`)}
className="h-4 w-4"
/>
<label className="ml-2 text-sm">Allow multiple selections</label>
</div>
</div>
);
}
// Main Survey Builder
export function SurveyBuilder() {
const {
register,
control,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<SurveyForm>({
resolver: zodResolver(surveySchema),
defaultValues: {
title: '',
description: '',
questions: [],
},
mode: 'onBlur',
});
const { fields, append, remove } = useFieldArray({
control,
name: 'questions',
});
const addQuestion = (type: Question['type']) => {
const baseQuestion = { question: '', required: false };
switch (type) {
case 'text':
append({ ...baseQuestion, type: 'text', maxLength: undefined });
break;
case 'choice':
append({ ...baseQuestion, type: 'choice', options: ['', ''], multiple: false });
break;
case 'rating':
append({ ...baseQuestion, type: 'rating', scale: '5' });
break;
}
};
const onSubmit = async (data: SurveyForm) => {
await api.createSurvey(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate className="space-y-6">
{/* Survey Info */}
<div className="space-y-4">
<div>
<label htmlFor="title" className="block text-sm font-medium">
Survey Title
</label>
<input
id="title"
{...register('title')}
aria-invalid={!!errors.title}
className="mt-1 block w-full rounded-md border px-3 py-2"
/>
{errors.title && (
<p role="alert" className="mt-1 text-sm text-red-600">
{errors.title.message}
</p>
)}
</div>
<div>
<label htmlFor="description" className="block text-sm font-medium">
Description (optional)
</label>
<textarea
id="description"
{...register('description')}
rows={2}
className="mt-1 block w-full rounded-md border px-3 py-2"
/>
</div>
</div>
{/* Questions */}
<div className="space-y-4">
<h2 className="text-lg font-semibold">Questions</h2>
{fields.map((field, index) => (
<QuestionEditor
key={field.id}
index={index}
control={control}
register={register}
remove={remove}
errors={errors}
/>
))}
{errors.questions?.message && (
<p role="alert" className="text-sm text-red-600">
{errors.questions.message}
</p>
)}
{/* Add Question Buttons */}
<div className="flex gap-2">
<button
type="button"
onClick={() => addQuestion('text')}
className="rounded-md border px-4 py-2 hover:bg-gray-50"
>
+ Text Question
</button>
<button
type="button"
onClick={() => addQuestion('choice')}
className="rounded-md border px-4 py-2 hover:bg-gray-50"
>
+ Choice Question
</button>
<button
type="button"
onClick={() => addQuestion('rating')}
className="rounded-md border px-4 py-2 hover:bg-gray-50"
>
+ Rating Question
</button>
</div>
</div>
{/* Submit */}
<button
type="submit"
disabled={isSubmitting}
className="rounded-md bg-blue-600 px-6 py-2 text-white disabled:opacity-50"
>
{isSubmitting ? 'Creating...' : 'Create Survey'}
</button>
</form>
);
}---
Quick Reference
// ✅ Basic form setup
const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm({
resolver: zodResolver(schema),
defaultValues: { /* all fields */ },
mode: 'onBlur',
});
// ✅ Field array with proper key
{fields.map((field, index) => (
<div key={field.id}> {/* NOT index! */}
<input {...register(`items.${index}.name`)} />
</div>
))}
// ✅ Controller for third-party components
<Controller
name="date"
control={control}
render={({ field, fieldState }) => (
<DatePicker {...field} error={fieldState.error} />
)}
/>
// ✅ Server Action with useActionState
const [state, formAction, isPending] = useActionState(serverAction, null);
<form action={formAction}>...</form>
// ✅ Step validation in wizard
const nextStep = async () => {
const isValid = await methods.trigger(stepFields);
if (isValid) setStep(s => s + 1);
};Advanced Zod Validation Patterns
Comprehensive guide to Zod validation for React Hook Form.
Validation Flow
┌─────────────────────────────────────────────────────────────────────────────┐
│ React Hook Form + Zod Flow │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ User Input ──► register() ──► Internal State ──► zodResolver ──► Errors │
│ │ │ │
│ ▼ ▼ │
│ handleSubmit() formState.errors │
│ │ │
│ ▼ │
│ onSubmit(data) ◄── data is typed & validated │
│ │
└─────────────────────────────────────────────────────────────────────────────┘Basic Schema Patterns
String Validation
const stringSchema = z.object({
// Required string with length constraints
name: z.string()
.min(2, 'Name must be at least 2 characters')
.max(100, 'Name must be less than 100 characters'),
// Email with custom message
email: z.string()
.min(1, 'Email is required')
.email('Please enter a valid email'),
// Optional string
bio: z.string().max(500).optional(),
// Nullable string (can be null)
middleName: z.string().nullable(),
// String with regex pattern
phone: z.string()
.regex(/^\+?[1-9]\d{1,14}$/, 'Invalid phone number'),
// URL validation
website: z.string().url('Please enter a valid URL').optional(),
// Trim whitespace and transform
username: z.string()
.trim()
.toLowerCase()
.min(3, 'Username must be at least 3 characters')
.max(20, 'Username must be less than 20 characters')
.regex(/^[a-z0-9_]+$/, 'Only lowercase letters, numbers, and underscores'),
});Number Validation
const numberSchema = z.object({
// Integer with range
age: z.number()
.int('Age must be a whole number')
.min(0, 'Age cannot be negative')
.max(150, 'Invalid age'),
// Decimal with precision
price: z.number()
.positive('Price must be positive')
.multipleOf(0.01, 'Price must have at most 2 decimal places'),
// Coerce from string input
quantity: z.coerce.number()
.int()
.min(1, 'Minimum quantity is 1')
.max(100, 'Maximum quantity is 100'),
// Optional number with default
discount: z.number().min(0).max(100).default(0),
});Date Validation
const dateSchema = z.object({
// Coerce from string input
birthDate: z.coerce.date()
.max(new Date(), 'Birth date cannot be in the future'),
// Date range
startDate: z.coerce.date(),
endDate: z.coerce.date(),
}).refine(
(data) => data.endDate > data.startDate,
{
message: 'End date must be after start date',
path: ['endDate'],
}
);
// ISO date string
const isoDateSchema = z.string()
.datetime({ message: 'Invalid date format' })
.transform((val) => new Date(val));Enum and Union Validation
// String enum
const roleSchema = z.enum(['admin', 'user', 'guest'], {
errorMap: () => ({ message: 'Please select a valid role' }),
});
// Native enum
enum Status {
Draft = 'draft',
Published = 'published',
Archived = 'archived',
}
const statusSchema = z.nativeEnum(Status);
// Union type
const idSchema = z.union([
z.string().uuid(),
z.number().int().positive(),
]);
// Literal union (simpler for small sets)
const prioritySchema = z.union([
z.literal('low'),
z.literal('medium'),
z.literal('high'),
]);Cross-Field Validation
Password Confirmation
const passwordSchema = z.object({
password: z.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Password must contain uppercase letter')
.regex(/[a-z]/, 'Password must contain lowercase letter')
.regex(/[0-9]/, 'Password must contain number')
.regex(/[^A-Za-z0-9]/, 'Password must contain special character'),
confirmPassword: z.string(),
}).refine(
(data) => data.password === data.confirmPassword,
{
message: "Passwords don't match",
path: ['confirmPassword'], // Error shows on confirmPassword field
}
);Date Range Validation
const dateRangeSchema = z.object({
startDate: z.coerce.date(),
endDate: z.coerce.date(),
}).refine(
(data) => data.endDate >= data.startDate,
{
message: 'End date must be on or after start date',
path: ['endDate'],
}
).refine(
(data) => {
const diffDays = (data.endDate.getTime() - data.startDate.getTime()) / (1000 * 60 * 60 * 24);
return diffDays <= 365;
},
{
message: 'Date range cannot exceed 1 year',
path: ['endDate'],
}
);Multiple Cross-Field Refinements
const orderSchema = z.object({
shippingMethod: z.enum(['standard', 'express', 'overnight']),
deliveryDate: z.coerce.date().optional(),
specialInstructions: z.string().optional(),
}).refine(
(data) => {
if (data.shippingMethod === 'overnight') {
return data.deliveryDate !== undefined;
}
return true;
},
{
message: 'Delivery date is required for overnight shipping',
path: ['deliveryDate'],
}
).refine(
(data) => {
if (data.shippingMethod === 'express' && data.specialInstructions) {
return data.specialInstructions.length <= 100;
}
return true;
},
{
message: 'Special instructions limited to 100 chars for express shipping',
path: ['specialInstructions'],
}
);Conditional Fields (Discriminated Unions)
Payment Method Selection
const paymentSchema = z.discriminatedUnion('method', [
// Credit Card
z.object({
method: z.literal('card'),
cardNumber: z.string()
.regex(/^\d{16}$/, 'Card number must be 16 digits'),
expiryMonth: z.number().int().min(1).max(12),
expiryYear: z.number().int().min(2024).max(2040),
cvv: z.string().regex(/^\d{3,4}$/, 'CVV must be 3-4 digits'),
cardholderName: z.string().min(2),
}),
// PayPal
z.object({
method: z.literal('paypal'),
email: z.string().email('Please enter a valid PayPal email'),
}),
// Bank Transfer
z.object({
method: z.literal('bank'),
iban: z.string()
.regex(/^[A-Z]{2}\d{2}[A-Z0-9]{4,}$/, 'Invalid IBAN format')
.min(15)
.max(34),
bankName: z.string().min(2),
swiftCode: z.string().regex(/^[A-Z]{6}[A-Z0-9]{2}([A-Z0-9]{3})?$/).optional(),
}),
// Crypto
z.object({
method: z.literal('crypto'),
walletAddress: z.string().min(26).max(62),
network: z.enum(['ethereum', 'bitcoin', 'polygon']),
}),
]);
type PaymentData = z.infer<typeof paymentSchema>;
// PaymentData is a union type with proper discriminationContact Method Selection
const contactSchema = z.discriminatedUnion('preferredContact', [
z.object({
preferredContact: z.literal('email'),
email: z.string().email(),
emailFrequency: z.enum(['daily', 'weekly', 'monthly']),
}),
z.object({
preferredContact: z.literal('phone'),
phone: z.string().regex(/^\+?[1-9]\d{1,14}$/),
callTime: z.enum(['morning', 'afternoon', 'evening']),
}),
z.object({
preferredContact: z.literal('mail'),
address: z.object({
street: z.string().min(5),
city: z.string().min(2),
postalCode: z.string().min(3),
country: z.string().min(2),
}),
}),
]);Async Validation
Username Availability
import { z } from 'zod';
import { debounce } from 'lodash-es';
// API call
const checkUsernameAvailable = async (username: string): Promise<boolean> => {
const res = await fetch(`/api/check-username?username=${encodeURIComponent(username)}`);
const data = await res.json();
return data.available;
};
// Debounced version
const debouncedCheck = debounce(checkUsernameAvailable, 300);
// Schema with async validation
const signupSchema = z.object({
username: z.string()
.min(3, 'Username must be at least 3 characters')
.max(20, 'Username must be less than 20 characters')
.regex(/^[a-z0-9_]+$/, 'Only lowercase letters, numbers, and underscores')
.refine(
async (username) => {
if (username.length < 3) return true; // Skip check if too short
return await debouncedCheck(username);
},
{ message: 'Username is already taken' }
),
email: z.string().email(),
password: z.string().min(8),
});
// Use with mode: 'onBlur' for best UX
const { register } = useForm({
resolver: zodResolver(signupSchema),
mode: 'onBlur', // Validates on blur, not every keystroke
});Email Domain Validation
const checkCompanyDomain = async (email: string): Promise<boolean> => {
const domain = email.split('@')[1];
const res = await fetch(`/api/validate-domain?domain=${domain}`);
const data = await res.json();
return data.valid;
};
const corporateEmailSchema = z.object({
email: z.string()
.email('Invalid email format')
.refine(
async (email) => {
// Skip validation for free email providers
const freeDomains = ['gmail.com', 'yahoo.com', 'hotmail.com'];
const domain = email.split('@')[1];
if (freeDomains.includes(domain)) {
return false; // Will show error
}
return await checkCompanyDomain(email);
},
{ message: 'Please use your company email address' }
),
});Transform and Preprocess
Data Transformation
const formSchema = z.object({
// Trim and normalize
name: z.string()
.trim()
.transform((val) => val.replace(/\s+/g, ' ')), // Normalize spaces
// Convert to lowercase
email: z.string().email().toLowerCase(),
// Parse number from string
age: z.string()
.transform((val) => parseInt(val, 10))
.pipe(z.number().int().min(0).max(150)),
// Parse date from string
birthDate: z.string()
.transform((val) => new Date(val))
.pipe(z.date().max(new Date(), 'Cannot be in future')),
// Currency string to cents
price: z.string()
.regex(/^\d+(\.\d{2})?$/, 'Invalid price format')
.transform((val) => Math.round(parseFloat(val) * 100)), // Store as cents
// Phone number normalization
phone: z.string()
.transform((val) => val.replace(/\D/g, '')) // Remove non-digits
.pipe(z.string().min(10).max(15)),
});Preprocessing Input
const searchSchema = z.object({
query: z.preprocess(
(val) => {
if (typeof val === 'string') {
return val.trim().toLowerCase();
}
return val;
},
z.string().min(1, 'Search query is required')
),
// Handle empty strings as undefined
optionalField: z.preprocess(
(val) => (val === '' ? undefined : val),
z.string().min(5).optional()
),
});File Validation
Single File Upload
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
const ACCEPTED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
const avatarSchema = z.object({
avatar: z
.instanceof(FileList)
.refine((files) => files.length === 1, 'Please select a file')
.refine((files) => files[0].size <= MAX_FILE_SIZE, 'Max file size is 5MB')
.refine(
(files) => ACCEPTED_IMAGE_TYPES.includes(files[0].type),
'Only JPEG, PNG, WebP, and GIF are accepted'
)
.transform((files) => files[0]), // Extract single file
});Multiple File Upload
const MAX_FILES = 10;
const MAX_TOTAL_SIZE = 50 * 1024 * 1024; // 50MB total
const ACCEPTED_DOC_TYPES = [
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
];
const documentsSchema = z.object({
documents: z
.instanceof(FileList)
.refine((files) => files.length > 0, 'At least one file is required')
.refine((files) => files.length <= MAX_FILES, `Maximum ${MAX_FILES} files`)
.refine(
(files) => {
const totalSize = Array.from(files).reduce((sum, f) => sum + f.size, 0);
return totalSize <= MAX_TOTAL_SIZE;
},
'Total file size must be less than 50MB'
)
.refine(
(files) => Array.from(files).every((f) => ACCEPTED_DOC_TYPES.includes(f.type)),
'Only PDF and Word documents are accepted'
)
.transform((files) => Array.from(files)),
});Array Validation
Field Array with Constraints
const orderItemSchema = z.object({
productId: z.string().uuid('Invalid product ID'),
quantity: z.number().int().min(1).max(100),
notes: z.string().max(200).optional(),
});
const orderSchema = z.object({
items: z
.array(orderItemSchema)
.min(1, 'At least one item is required')
.max(50, 'Maximum 50 items per order')
.refine(
(items) => {
// Check for duplicate products
const productIds = items.map((i) => i.productId);
return new Set(productIds).size === productIds.length;
},
{ message: 'Duplicate products are not allowed' }
)
.refine(
(items) => {
// Check total quantity
const total = items.reduce((sum, i) => sum + i.quantity, 0);
return total <= 500;
},
{ message: 'Total quantity cannot exceed 500' }
),
couponCode: z.string().optional(),
});Partial and Pick/Omit
Update Forms (Partial Schema)
const userSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
name: z.string().min(2),
avatar: z.string().url().optional(),
settings: z.object({
notifications: z.boolean(),
theme: z.enum(['light', 'dark', 'system']),
}),
});
// All fields optional (for PATCH updates)
const updateUserSchema = userSchema.partial();
// Specific fields optional
const profileUpdateSchema = userSchema.pick({
name: true,
avatar: true,
}).partial();
// Deep partial (nested objects too)
const deepUpdateSchema = userSchema.deepPartial();
// Omit sensitive fields
const publicUserSchema = userSchema.omit({
settings: true,
});Custom Error Messages
Global Error Map
const customErrorMap: z.ZodErrorMap = (issue, ctx) => {
if (issue.code === z.ZodIssueCode.invalid_type) {
if (issue.expected === 'string') {
return { message: 'This field is required' };
}
if (issue.expected === 'number') {
return { message: 'Please enter a valid number' };
}
}
if (issue.code === z.ZodIssueCode.too_small) {
if (issue.type === 'string') {
return { message: `Must be at least ${issue.minimum} characters` };
}
}
return { message: ctx.defaultError };
};
// Apply globally
z.setErrorMap(customErrorMap);Field-Level Error Messages
const formSchema = z.object({
email: z.string({
required_error: 'Email address is required',
invalid_type_error: 'Email must be text',
}).email({
message: 'Please enter a valid email address',
}),
password: z.string({
required_error: 'Password is required',
})
.min(8, { message: 'Password must be at least 8 characters long' })
.regex(/[A-Z]/, { message: 'Password must include an uppercase letter' })
.regex(/[0-9]/, { message: 'Password must include a number' }),
age: z.number({
required_error: 'Age is required',
invalid_type_error: 'Age must be a number',
})
.int({ message: 'Age must be a whole number' })
.min(18, { message: 'You must be at least 18 years old' })
.max(120, { message: 'Please enter a valid age' }),
});Create form: $ARGUMENTS
Form Context (Auto-Detected)
- Form Library: !
grep -r "react-hook-form\|formik" package.json 2>/dev/null | head -1 | grep -oE 'react-hook-form|formik' || echo "react-hook-form (recommended)" - Validation Library: !
grep -r "zod\|yup" package.json 2>/dev/null | head -1 | grep -oE 'zod|yup' || echo "zod (recommended)" - Existing Forms: !
find . -name "*form*.tsx" -o -name "*Form*.tsx" 2>/dev/null | wc -l | tr -d ' ' || echo "0" - Components Directory: !
find . -type d \( -name "components" -o -name "src/components" \) 2>/dev/null | head -1 || echo "components"
Form Template
/**
* $ARGUMENTS Form
*
* Generated: !`date +%Y-%m-%d`
* Library: !`grep -r "react-hook-form" package.json 2>/dev/null && echo "react-hook-form" || echo "react-hook-form (install: npm install react-hook-form)"`
*/
'use client';
import { useForm } from 'react-hook-form';
!`grep -q "zod" package.json 2>/dev/null && echo "import { zodResolver } from '@hookform/resolvers/zod';" || echo "// Install: npm install @hookform/resolvers zod"`
!`grep -q "zod" package.json 2>/dev/null && echo "import { z } from 'zod';" || echo "// Install: npm install zod"`
!`grep -q "zod" package.json 2>/dev/null && echo "const schema = z.object({
// Add your fields here
});" || echo "// Define your schema"`
export function $ARGUMENTS() {
const form = useForm({
!`grep -q "zod" package.json 2>/dev/null && echo "resolver: zodResolver(schema)," || echo "// Add resolver if using zod"`
});
return (
<form onSubmit={form.handleSubmit(onSubmit)}>
{/* Add form fields */}
</form>
);
}Usage
1. Review detected libraries above 2. Save to: components/forms/$ARGUMENTS.tsx 3. Customize schema and fields
/**
* Production Form Template with React Hook Form + Zod
*
* Features:
* - Type-safe forms with Zod schema inference
* - Field arrays with add/remove
* - Controlled component integration
* - Server-side validation (Server Actions)
* - Full accessibility support
* - Loading states and error handling
* - Multi-step wizard pattern
*
* Usage:
* 1. Copy this template
* 2. Modify schema for your fields
* 3. Update API endpoint
* 4. Customize field components
*/
'use client';
import { useState, useEffect } from 'react';
import {
useForm,
useFieldArray,
Controller,
FormProvider,
useFormContext,
type SubmitHandler,
type FieldErrors,
} from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
// ============================================
// Schema Definition
// ============================================
const addressSchema = z.object({
street: z.string().min(5, 'Street address is required'),
city: z.string().min(2, 'City is required'),
state: z.string().min(2, 'State is required'),
postalCode: z.string().regex(/^\d{5}(-\d{4})?$/, 'Invalid postal code'),
country: z.string().min(2, 'Country is required'),
});
const contactSchema = z.object({
type: z.enum(['home', 'work', 'mobile']),
value: z.string().min(1, 'Contact value is required'),
});
const formSchema = z.object({
// Basic fields
firstName: z.string()
.min(2, 'First name must be at least 2 characters')
.max(50, 'First name must be less than 50 characters'),
lastName: z.string()
.min(2, 'Last name must be at least 2 characters')
.max(50, 'Last name must be less than 50 characters'),
email: z.string()
.min(1, 'Email is required')
.email('Please enter a valid email'),
phone: z.string()
.regex(/^\+?[1-9]\d{1,14}$/, 'Please enter a valid phone number')
.optional()
.or(z.literal('')),
// Nested object
address: addressSchema,
// Field array
contacts: z.array(contactSchema)
.min(1, 'At least one contact is required')
.max(5, 'Maximum 5 contacts allowed'),
// Optional fields
bio: z.string().max(500, 'Bio must be less than 500 characters').optional(),
birthDate: z.coerce.date().max(new Date(), 'Cannot be in the future').optional(),
// Boolean
newsletter: z.boolean().default(false),
terms: z.boolean().refine((val) => val === true, 'You must accept the terms'),
});
type FormData = z.infer<typeof formSchema>;
// ============================================
// Field Components
// ============================================
interface FormFieldProps {
name: keyof FormData | `address.${keyof FormData['address']}`;
label: string;
type?: 'text' | 'email' | 'tel' | 'date' | 'textarea';
placeholder?: string;
required?: boolean;
}
function FormField({ name, label, type = 'text', placeholder, required }: FormFieldProps) {
const {
register,
formState: { errors },
} = useFormContext<FormData>();
// Navigate to nested error
const error = name.includes('.')
? (errors as any)[name.split('.')[0]]?.[name.split('.')[1]]
: (errors as any)[name];
const inputId = `field-${name}`;
const errorId = `${inputId}-error`;
const commonProps = {
id: inputId,
placeholder,
'aria-invalid': !!error,
'aria-describedby': error ? errorId : undefined,
'aria-required': required,
className: `mt-1 block w-full rounded-md border px-3 py-2 ${
error ? 'border-red-500' : 'border-gray-300'
}`,
};
return (
<div>
<label htmlFor={inputId} className="block text-sm font-medium text-gray-700">
{label}
{required && <span className="text-red-500 ml-1">*</span>}
</label>
{type === 'textarea' ? (
<textarea rows={4} {...register(name as any)} {...commonProps} />
) : (
<input type={type} {...register(name as any)} {...commonProps} />
)}
{error && (
<p id={errorId} role="alert" className="mt-1 text-sm text-red-600">
{error.message as string}
</p>
)}
</div>
);
}
// ============================================
// Contact Field Array
// ============================================
function ContactsFieldArray() {
const {
control,
register,
formState: { errors },
} = useFormContext<FormData>();
const { fields, append, remove } = useFieldArray({
control,
name: 'contacts',
});
const contactsError = errors.contacts?.message || errors.contacts?.root?.message;
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-medium">Contact Methods</h3>
<button
type="button"
onClick={() => append({ type: 'mobile', value: '' })}
disabled={fields.length >= 5}
className="text-sm text-blue-600 hover:text-blue-800 disabled:text-gray-400"
>
+ Add Contact
</button>
</div>
{contactsError && (
<p role="alert" className="text-sm text-red-600">
{contactsError}
</p>
)}
{fields.map((field, index) => (
<div key={field.id} className="flex gap-4 items-start">
<div className="w-32">
<select
{...register(`contacts.${index}.type`)}
className="block w-full rounded-md border border-gray-300 px-3 py-2"
>
<option value="mobile">Mobile</option>
<option value="home">Home</option>
<option value="work">Work</option>
</select>
</div>
<div className="flex-1">
<input
{...register(`contacts.${index}.value`)}
placeholder="Contact value"
aria-invalid={!!errors.contacts?.[index]?.value}
className={`block w-full rounded-md border px-3 py-2 ${
errors.contacts?.[index]?.value ? 'border-red-500' : 'border-gray-300'
}`}
/>
{errors.contacts?.[index]?.value && (
<p role="alert" className="mt-1 text-sm text-red-600">
{errors.contacts[index]?.value?.message}
</p>
)}
</div>
<button
type="button"
onClick={() => remove(index)}
disabled={fields.length <= 1}
className="text-red-500 hover:text-red-700 disabled:text-gray-400 p-2"
aria-label={`Remove contact ${index + 1}`}
>
×
</button>
</div>
))}
</div>
);
}
// ============================================
// Controlled Component Example
// ============================================
interface DatePickerProps {
value: Date | undefined;
onChange: (date: Date | undefined) => void;
onBlur: () => void;
error?: string;
}
function DatePickerField({ value, onChange, onBlur, error }: DatePickerProps) {
// This would be your actual date picker component
return (
<div>
<input
type="date"
value={value ? value.toISOString().split('T')[0] : ''}
onChange={(e) => onChange(e.target.value ? new Date(e.target.value) : undefined)}
onBlur={onBlur}
aria-invalid={!!error}
className={`block w-full rounded-md border px-3 py-2 ${
error ? 'border-red-500' : 'border-gray-300'
}`}
/>
{error && (
<p role="alert" className="mt-1 text-sm text-red-600">
{error}
</p>
)}
</div>
);
}
// ============================================
// Main Form Component
// ============================================
interface ProfileFormProps {
defaultValues?: Partial<FormData>;
onSubmitSuccess?: (data: FormData) => void;
}
export function ProfileForm({ defaultValues, onSubmitSuccess }: ProfileFormProps) {
const [serverError, setServerError] = useState<string | null>(null);
const methods = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: {
firstName: '',
lastName: '',
email: '',
phone: '',
address: {
street: '',
city: '',
state: '',
postalCode: '',
country: 'US',
},
contacts: [{ type: 'mobile', value: '' }],
bio: '',
newsletter: false,
terms: false,
...defaultValues,
},
mode: 'onBlur', // Validate on blur for better UX
});
const {
handleSubmit,
control,
reset,
setError,
formState: { errors, isSubmitting, isSubmitSuccessful, isDirty },
} = methods;
const onSubmit: SubmitHandler<FormData> = async (data) => {
setServerError(null);
try {
const response = await fetch('/api/profile', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!response.ok) {
const error = await response.json();
// Handle field-specific server errors
if (error.fieldErrors) {
Object.entries(error.fieldErrors).forEach(([field, message]) => {
setError(field as keyof FormData, {
type: 'server',
message: message as string,
});
});
return;
}
throw new Error(error.message || 'Failed to save profile');
}
const result = await response.json();
onSubmitSuccess?.(result);
} catch (error) {
setServerError(error instanceof Error ? error.message : 'An error occurred');
throw error; // Re-throw to keep isSubmitting accurate
}
};
// Success message
if (isSubmitSuccessful) {
return (
<div role="status" className="p-6 bg-green-50 rounded-lg text-center">
<h2 className="text-xl font-semibold text-green-800">Profile Saved!</h2>
<p className="text-green-600 mt-2">Your changes have been saved successfully.</p>
<button
type="button"
onClick={() => reset()}
className="mt-4 text-green-700 underline"
>
Make more changes
</button>
</div>
);
}
return (
<FormProvider {...methods}>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6" noValidate>
{/* Server Error */}
{serverError && (
<div role="alert" className="p-4 bg-red-50 border border-red-200 rounded-lg">
<p className="text-red-700">{serverError}</p>
</div>
)}
{/* Personal Information */}
<fieldset className="border border-gray-200 rounded-lg p-4">
<legend className="text-lg font-medium px-2">Personal Information</legend>
<div className="grid grid-cols-2 gap-4 mt-4">
<FormField name="firstName" label="First Name" required />
<FormField name="lastName" label="Last Name" required />
</div>
<div className="grid grid-cols-2 gap-4 mt-4">
<FormField name="email" label="Email" type="email" required />
<FormField name="phone" label="Phone" type="tel" placeholder="+1234567890" />
</div>
<div className="mt-4">
<label className="block text-sm font-medium text-gray-700">Birth Date</label>
<Controller
name="birthDate"
control={control}
render={({ field, fieldState }) => (
<DatePickerField
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
error={fieldState.error?.message}
/>
)}
/>
</div>
</fieldset>
{/* Address */}
<fieldset className="border border-gray-200 rounded-lg p-4">
<legend className="text-lg font-medium px-2">Address</legend>
<div className="space-y-4 mt-4">
<FormField name="address.street" label="Street Address" required />
<div className="grid grid-cols-2 gap-4">
<FormField name="address.city" label="City" required />
<FormField name="address.state" label="State" required />
</div>
<div className="grid grid-cols-2 gap-4">
<FormField name="address.postalCode" label="Postal Code" required />
<FormField name="address.country" label="Country" required />
</div>
</div>
</fieldset>
{/* Contact Methods */}
<fieldset className="border border-gray-200 rounded-lg p-4">
<legend className="text-lg font-medium px-2">Contact Methods</legend>
<div className="mt-4">
<ContactsFieldArray />
</div>
</fieldset>
{/* Bio */}
<div>
<FormField name="bio" label="Bio" type="textarea" />
</div>
{/* Checkboxes */}
<div className="space-y-4">
<label className="flex items-center gap-3">
<input
type="checkbox"
{...methods.register('newsletter')}
className="rounded border-gray-300"
/>
<span className="text-sm text-gray-700">Subscribe to newsletter</span>
</label>
<div>
<label className="flex items-center gap-3">
<input
type="checkbox"
{...methods.register('terms')}
aria-invalid={!!errors.terms}
className="rounded border-gray-300"
/>
<span className="text-sm text-gray-700">
I accept the <a href="/terms" className="text-blue-600 underline">terms and conditions</a>
<span className="text-red-500 ml-1">*</span>
</span>
</label>
{errors.terms && (
<p role="alert" className="mt-1 text-sm text-red-600">
{errors.terms.message}
</p>
)}
</div>
</div>
{/* Submit Button */}
<div className="flex gap-4">
<button
type="submit"
disabled={isSubmitting}
className="flex-1 rounded-md bg-blue-600 px-4 py-3 text-white font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSubmitting ? (
<span className="flex items-center justify-center gap-2">
<span className="animate-spin">⏳</span>
Saving...
</span>
) : (
'Save Profile'
)}
</button>
<button
type="button"
onClick={() => reset()}
disabled={!isDirty || isSubmitting}
className="px-4 py-3 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50 disabled:opacity-50"
>
Reset
</button>
</div>
</form>
</FormProvider>
);
}
// ============================================
// Multi-Step Wizard Example
// ============================================
const wizardSteps = ['account', 'profile', 'preferences'] as const;
type WizardStep = (typeof wizardSteps)[number];
const wizardSchema = z.object({
account: z.object({
email: z.string().email('Please enter a valid email'),
password: z.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Must contain uppercase')
.regex(/[0-9]/, 'Must contain number'),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ['confirmPassword'],
}),
profile: z.object({
name: z.string().min(2, 'Name is required'),
avatar: z.string().url().optional(),
}),
preferences: z.object({
newsletter: z.boolean(),
notifications: z.enum(['all', 'important', 'none']),
}),
});
type WizardData = z.infer<typeof wizardSchema>;
export function WizardForm() {
const [currentStep, setCurrentStep] = useState(0);
const step = wizardSteps[currentStep];
const methods = useForm<WizardData>({
resolver: zodResolver(wizardSchema),
mode: 'onTouched',
defaultValues: {
account: { email: '', password: '', confirmPassword: '' },
profile: { name: '', avatar: '' },
preferences: { newsletter: true, notifications: 'important' },
},
});
const {
handleSubmit,
trigger,
formState: { isSubmitting },
} = methods;
const nextStep = async () => {
const isValid = await trigger(step);
if (isValid) {
setCurrentStep((s) => Math.min(s + 1, wizardSteps.length - 1));
}
};
const prevStep = () => {
setCurrentStep((s) => Math.max(s - 1, 0));
};
const onSubmit: SubmitHandler<WizardData> = async (data) => {
await fetch('/api/signup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
};
return (
<FormProvider {...methods}>
<form onSubmit={handleSubmit(onSubmit)} className="max-w-md mx-auto">
{/* Progress Indicator */}
<div className="flex justify-between mb-8">
{wizardSteps.map((s, index) => (
<div
key={s}
className={`flex items-center ${
index < currentStep
? 'text-green-600'
: index === currentStep
? 'text-blue-600'
: 'text-gray-400'
}`}
>
<div
className={`w-8 h-8 rounded-full flex items-center justify-center border-2 ${
index <= currentStep ? 'border-current bg-current/10' : 'border-gray-300'
}`}
>
{index < currentStep ? '✓' : index + 1}
</div>
<span className="ml-2 text-sm capitalize">{s}</span>
</div>
))}
</div>
{/* Step Content */}
<div className="min-h-[300px]">
{step === 'account' && <AccountStep />}
{step === 'profile' && <ProfileStep />}
{step === 'preferences' && <PreferencesStep />}
</div>
{/* Navigation */}
<div className="flex justify-between mt-8">
<button
type="button"
onClick={prevStep}
disabled={currentStep === 0}
className="px-4 py-2 border rounded disabled:opacity-50"
>
Back
</button>
{currentStep < wizardSteps.length - 1 ? (
<button
type="button"
onClick={nextStep}
className="px-4 py-2 bg-blue-600 text-white rounded"
>
Next
</button>
) : (
<button
type="submit"
disabled={isSubmitting}
className="px-4 py-2 bg-green-600 text-white rounded disabled:opacity-50"
>
{isSubmitting ? 'Creating...' : 'Create Account'}
</button>
)}
</div>
</form>
</FormProvider>
);
}
// Step Components
function AccountStep() {
const { register, formState: { errors } } = useFormContext<WizardData>();
return (
<div className="space-y-4">
<h2 className="text-xl font-semibold">Create Account</h2>
<div>
<input
{...register('account.email')}
type="email"
placeholder="Email"
className="w-full p-3 border rounded"
/>
{errors.account?.email && (
<p className="text-red-500 text-sm mt-1">{errors.account.email.message}</p>
)}
</div>
<div>
<input
{...register('account.password')}
type="password"
placeholder="Password"
className="w-full p-3 border rounded"
/>
{errors.account?.password && (
<p className="text-red-500 text-sm mt-1">{errors.account.password.message}</p>
)}
</div>
<div>
<input
{...register('account.confirmPassword')}
type="password"
placeholder="Confirm Password"
className="w-full p-3 border rounded"
/>
{errors.account?.confirmPassword && (
<p className="text-red-500 text-sm mt-1">{errors.account.confirmPassword.message}</p>
)}
</div>
</div>
);
}
function ProfileStep() {
const { register, formState: { errors } } = useFormContext<WizardData>();
return (
<div className="space-y-4">
<h2 className="text-xl font-semibold">Your Profile</h2>
<div>
<input
{...register('profile.name')}
placeholder="Your Name"
className="w-full p-3 border rounded"
/>
{errors.profile?.name && (
<p className="text-red-500 text-sm mt-1">{errors.profile.name.message}</p>
)}
</div>
<div>
<input
{...register('profile.avatar')}
placeholder="Avatar URL (optional)"
className="w-full p-3 border rounded"
/>
</div>
</div>
);
}
function PreferencesStep() {
const { register } = useFormContext<WizardData>();
return (
<div className="space-y-4">
<h2 className="text-xl font-semibold">Preferences</h2>
<label className="flex items-center gap-3">
<input type="checkbox" {...register('preferences.newsletter')} />
<span>Subscribe to newsletter</span>
</label>
<div>
<label className="block mb-2">Notifications</label>
<select {...register('preferences.notifications')} className="w-full p-3 border rounded">
<option value="all">All notifications</option>
<option value="important">Important only</option>
<option value="none">None</option>
</select>
</div>
</div>
);
}