--- name: form-validation-architect description: End-to-end form handling with react-hook-form, Zod schemas, validation patterns, error messaging, field arrays, and multi-step wizards. Use for complex forms, validation architecture, autosave, field dependencies. Activate on "form validation", "react-hook-form", "Zod", "form error", "multi-step form", "wizard". NOT for simple HTML forms, backend validation only, or non-React frameworks. allowed-tools: Read,Write,Edit,Bash(npm:*) --- # Form Validation Architect Expert in building production-grade form systems with client-side validation, type safety, and excellent UX. ## When to Use ✅ **Use for**: - Complex forms with multiple fields and validation rules - Multi-step wizards with progress tracking - Dynamic field arrays (add/remove items) - Form state persistence across sessions - Async validation (check username availability, validate address) - Dependent fields (enable B when A is checked) - File uploads with progress and validation - Autosave and optimistic updates ❌ **NOT for**: - Simple contact forms (HTML + basic JS is fine) - Backend-only validation (use Joi, Yup on server) - Non-React frameworks (use Formik alternatives) - Read-only displays (no form needed) ## Quick Decision Tree ``` Does your form: ├── Have >5 fields? → Use react-hook-form ├── Need type safety? → Add Zod schemas ├── Have dynamic fields? → Use field arrays ├── Span multiple steps? → Use wizard pattern ├── Need async validation? → Use resolver + async rules └── Just email/message? → Use native HTML validation ``` --- ## Technology Selection (2024+) ### React Hook Form (Recommended) **Why RHF over Formik**: - **Performance**: Uncontrolled inputs → fewer re-renders - **Bundle size**: 8KB vs 30KB (Formik) - **DevEx**: Better TypeScript support - **Adoption**: 40k+ stars, industry standard 2023+ **Timeline**: - 2015-2019: Formik dominated - 2019: React Hook Form released - 2022+: RHF became standard - 2024: Formik in maintenance mode ### Zod for Schema Validation **Why Zod over Yup**: - **TypeScript-first**: Infer types from schemas - **Composability**: Better schema reuse - **Error messages**: More customizable - **Modern**: Active development, latest features **Timeline**: - 2017-2020: Yup standard - 2020: Zod released - 2023+: Zod preferred for new projects --- ## Common Anti-Patterns ### Anti-Pattern 1: Controlled Inputs Everywhere **Novice thinking**: "All form inputs should be controlled with useState" **Problem**: Causes re-render on every keystroke **Wrong approach**: ```typescript // ❌ Re-renders entire component on every keystroke const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [name, setName] = useState(''); // ... 20 more useState calls setEmail(e.target.value)} /> ``` **Correct approach**: ```typescript // ✅ Uncontrolled with react-hook-form (minimal re-renders) const { register, handleSubmit } = useForm(); ``` **Why it matters**: Forms with 10+ fields become sluggish with controlled inputs. --- ### Anti-Pattern 2: String-Based Validation **Problem**: No type safety, easy to make mistakes **Wrong approach**: ```typescript // ❌ String validation, no types const validate = (values) => { if (!values.email.includes('@')) return 'Invalid email'; if (values.age < 18) return 'Must be 18+'; // Typo in field name? Runtime error! }; ``` **Correct approach**: ```typescript // ✅ Zod schema with type inference const schema = z.object({ email: z.string().email('Invalid email'), age: z.number().min(18, 'Must be 18+'), username: z.string() .min(3, 'Too short') .regex(/^[a-z0-9_]+$/, 'Lowercase, numbers, underscores only') }); type FormData = z.infer; // Automatic TypeScript type! ``` **Timeline**: - Pre-2020: String-based validation common - 2020+: Schema-first validation standard - 2024: Type inference from schemas expected --- ### Anti-Pattern 3: No Error State Management **Problem**: Errors shown before user interacts **Wrong approach**: ```typescript // ❌ Shows errors immediately on page load {errors.email && {errors.email}} ``` **Correct approach**: ```typescript // ✅ Show errors only after field is touched const { formState: { errors, touchedFields } } = useForm(); {touchedFields.email && errors.email && ( {errors.email.message} )} // Or: Use mode="onBlur" to validate on blur const form = useForm({ mode: 'onBlur' // Validate when user leaves field }); ``` **Why it matters**: Better UX → user isn't yelled at before typing --- ### Anti-Pattern 4: No Async Validation **Problem**: Can't check username availability, validate addresses, etc. **Correct approach**: ```typescript // ✅ Async validation with debounce const schema = z.object({ username: z.string().refine( async (username) => { // Debounced API call const available = await checkUsernameAvailability(username); return available; }, { message: 'Username already taken' } ) }); // Or: Custom async validation in RHF register('username', { validate: { checkAvailable: async (value) => { const response = await fetch(`/api/check-username?q=${value}`); return response.ok || 'Username taken'; } } }); ``` **Best practice**: Debounce async validation to avoid API spam --- ### Anti-Pattern 5: No Loading States **Problem**: User doesn't know validation is happening **Correct approach**: ```typescript // ✅ Show loading state during async validation const { formState: { isValidating, isSubmitting } } = useForm(); ``` --- ## Implementation Patterns ### Pattern 1: Basic Form with Zod ```typescript import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; // Define schema const loginSchema = z.object({ email: z.string().email('Invalid email address'), password: z.string().min(8, 'Password must be at least 8 characters'), rememberMe: z.boolean().optional() }); type LoginForm = z.infer; function LoginForm() { const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm({ resolver: zodResolver(loginSchema), defaultValues: { rememberMe: false } }); const onSubmit = async (data: LoginForm) => { await api.login(data); }; return (
{errors.email && {errors.email.message}}
{errors.password && {errors.password.message}}
); } ``` ### Pattern 2: Multi-Step Wizard ```typescript const stepSchemas = [ // Step 1: Personal Info z.object({ firstName: z.string().min(1, 'Required'), lastName: z.string().min(1, 'Required'), email: z.string().email() }), // Step 2: Address z.object({ street: z.string().min(1, 'Required'), city: z.string().min(1, 'Required'), zipCode: z.string().regex(/^\d{5}$/, 'Invalid ZIP') }), // Step 3: Payment z.object({ cardNumber: z.string().regex(/^\d{16}$/, 'Invalid card'), expiry: z.string().regex(/^\d{2}\/\d{2}$/, 'MM/YY format'), cvv: z.string().regex(/^\d{3}$/, '3 digits') }) ]; function MultiStepForm() { const [step, setStep] = useState(0); const [formData, setFormData] = useState({}); const form = useForm({ resolver: zodResolver(stepSchemas[step]) }); const nextStep = async () => { const isValid = await form.trigger(); // Validate current step if (isValid) { setFormData({ ...formData, ...form.getValues() }); setStep(step + 1); } }; const prevStep = () => { setFormData({ ...formData, ...form.getValues() }); setStep(step - 1); }; const onSubmit = async (data) => { const finalData = { ...formData, ...data }; await api.submitApplication(finalData); }; return (
{step === 0 && } {step === 1 && } {step === 2 && }
{step > 0 && }
); } ``` ### Pattern 3: Dynamic Field Arrays ```typescript const schema = z.object({ items: z.array(z.object({ name: z.string().min(1, 'Required'), quantity: z.number().min(1, 'At least 1'), price: z.number().min(0, 'Must be positive') })).min(1, 'Add at least one item') }); function OrderForm() { const { register, control, handleSubmit, formState: { errors } } = useForm({ resolver: zodResolver(schema), defaultValues: { items: [{ name: '', quantity: 1, price: 0 }] } }); const { fields, append, remove } = useFieldArray({ control, name: 'items' }); return (
{fields.map((field, index) => (
))}
); } ``` ### Pattern 4: Autosave (Debounced) ```typescript import { useDebounce } from 'use-debounce'; import { useEffect } from 'react'; function AutosaveForm() { const { watch, register } = useForm(); const formValues = watch(); // Watch all fields // Debounce to avoid saving on every keystroke const [debouncedValues] = useDebounce(formValues, 1000); useEffect(() => { // Save to localStorage or API localStorage.setItem('draft', JSON.stringify(debouncedValues)); // Or: await api.saveDraft(debouncedValues); }, [debouncedValues]); return (