In 2025, web forms will continue to be one of the main points of interaction between users and applications. With React Hook Form evolving significantly, let's explore best practices for creating complex forms that not only work well, but also provide an exceptional user experience.
UX Fundamentals in Forms
Essential Principles
- Immediate Feedback: Real-time validation
- Error Prevention: Guide the user before an error occurs
- Error Recovery: Clear messages and corrective actions
- Natural Progression: Logical filling flow
- Accessibility: Support different needs
Implementation with React Hook Form
Initial Setup with TypeScript
// src/types/form.ts interface FormData { personalInfo: { name: string; email: string; phone: string; }; address: { street: string; city: string; state: string; zipCode: string; }; preferences: { notifications: boolean; theme: 'light' | 'dark'; language: string; }; } // src/components/ComplexForm.tsx import { useForm, useFieldArray } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import * as z from 'zod'; const formSchema = z.object({ personalInfo: z.object({ name: z.string().min(2, 'Nome muito curto'), email: z.string().email('Email inválido'), phone: z.string().regex(/^\d{10,11}$/, 'Telefone inválido'), }), address: z.object({ street: z.string().min(5, 'Endereço muito curto'), city: z.string().min(2, 'Cidade inválida'), state: z.string().length(2, 'Use a sigla do estado'), zipCode: z.string().regex(/^\d{8}$/, 'CEP inválido'), }), preferences: z.object({ notifications: z.boolean(), theme: z.enum(['light', 'dark']), language: z.string(), }), });
Base Form Component
// src/components/SmartForm/index.tsx import * as React from 'react'; import { useForm } from 'react-hook-form'; import { cn } from '@/lib/utils'; interface SmartFormProps<T> { defaultValues: T; onSubmit: (data: T) => Promise<void>; children: React.ReactNode; className?: string; } export function SmartForm<T>({ defaultValues, onSubmit, children, className, }: SmartFormProps<T>) { const form = useForm<T>({ defaultValues, mode: 'onChange', }); const [isSubmitting, setIsSubmitting] = React.useState(false); const [submitError, setSubmitError] = React.useState<string | null>(null); const handleSubmit = async (data: T) => { try { setIsSubmitting(true); setSubmitError(null); await onSubmit(data); } catch (error) { setSubmitError(error.message); } finally { setIsSubmitting(false); } }; return ( <form onSubmit={form.handleSubmit(handleSubmit)} className={cn('space-y-8', className)} > <FormProvider {...form}> {children} </FormProvider> {submitError && ( <div className="text-red-500 text-sm mt-2"> {submitError} </div> )} <button type="submit" disabled={isSubmitting} className={cn( 'px-4 py-2 bg-primary text-white rounded-md', 'disabled:opacity-50 disabled:cursor-not-allowed' )} > {isSubmitting ? 'Enviando...' : 'Enviar'} </button> </form> ); }
Smart Fields with Feedback
// src/components/SmartField/index.tsx import * as React from 'react'; import { useFormContext } from 'react-hook-form'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Info, AlertCircle } from 'lucide-react'; import { Tooltip, TooltipContent, TooltipTrigger, } from '@/components/ui/tooltip'; interface SmartFieldProps { name: string; label: string; help?: string; type?: string; placeholder?: string; validation?: Record<string, unknown>; } export function SmartField({ name, label, help, type = 'text', placeholder, validation, }: SmartFieldProps) { const { register, formState: { errors }, watch, } = useFormContext(); const value = watch(name); const error = errors[name]; return ( <div className="space-y-2"> <div className="flex items-center space-x-2"> <Label htmlFor={name}>{label}</Label> {help && ( <Tooltip> <TooltipTrigger asChild> <Info className="h-4 w-4 text-muted-foreground" /> </TooltipTrigger> <TooltipContent>{help}</TooltipContent> </Tooltip> )} </div> <div className="relative"> <Input id={name} type={type} placeholder={placeholder} {...register(name, validation)} className={cn( 'w-full', error && 'border-red-500 focus:border-red-500' )} aria-describedby={`${name}-error`} /> {error && ( <div className="absolute right-2 top-1/2 -translate-y-1/2" id={`${name}-error`} > <Tooltip> <TooltipTrigger asChild> <AlertCircle className="h-4 w-4 text-red-500" /> </TooltipTrigger> <TooltipContent> {error.message} </TooltipContent> </Tooltip> </div> )} </div> {value && !error && ( <p className="text-sm text-green-500"> ✓ Campo preenchido corretamente </p> )} </div> ); }
Multi-step Form
// src/components/MultiStepForm/index.tsx import * as React from 'react'; import { useForm, FormProvider } from 'react-hook-form'; import { motion, AnimatePresence } from 'framer-motion'; interface Step { id: string; title: string; component: React.ComponentType; validation: Record<string, unknown>; } interface MultiStepFormProps { steps: Step[]; onComplete: (data: any) => Promise<void>; } export function MultiStepForm({ steps, onComplete, }: MultiStepFormProps) { const [currentStep, setCurrentStep] = React.useState(0); const [formData, setFormData] = React.useState({}); const form = useForm({ mode: 'onChange', }); const { handleSubmit, trigger } = form; const nextStep = async () => { const fields = Object.keys(steps[currentStep].validation); const isValid = await trigger(fields); if (isValid) { const stepData = form.getValues(fields); setFormData((prev) => ({ ...prev, ...stepData })); setCurrentStep((prev) => prev + 1); } }; const previousStep = () => { setCurrentStep((prev) => prev - 1); }; const onSubmit = async (data: any) => { const finalData = { ...formData, ...data }; await onComplete(finalData); }; const CurrentStepComponent = steps[currentStep].component; return ( <FormProvider {...form}> <div className="space-y-8"> {/* Progress Bar */} <div className="relative pt-1"> <div className="flex mb-2 items-center justify-between"> {steps.map((step, index) => ( <div key={step.id} className={cn( 'text-xs font-semibold inline-block py-1', currentStep >= index ? 'text-primary' : 'text-gray-400' )} > {step.title} </div> ))} </div> <div className="overflow-hidden h-2 mb-4 rounded bg-gray-200"> <motion.div className="h-full bg-primary" initial= animate= transition= /> </div> </div> {/* Form Steps */} <AnimatePresence mode="wait"> <motion.div key={currentStep} initial= animate= exit= > <CurrentStepComponent /> </motion.div> </AnimatePresence> {/* Navigation */} <div className="flex justify-between pt-4"> <button type="button" onClick={previousStep} disabled={currentStep === 0} className={cn( 'px-4 py-2 bg-gray-200 rounded-md', 'disabled:opacity-50 disabled:cursor-not-allowed' )} > Anterior </button> {currentStep === steps.length - 1 ? ( <button type="submit" onClick={handleSubmit(onSubmit)} className="px-4 py-2 bg-primary text-white rounded-md" > Concluir </button> ) : ( <button type="button" onClick={nextStep} className="px-4 py-2 bg-primary text-white rounded-md" > Próximo </button> )} </div> </div> </FormProvider> ); }
Asynchronous Validation
// src/hooks/useAsyncValidation.ts import * as React from 'react'; import { useFormContext } from 'react-hook-form'; import { debounce } from 'lodash'; interface AsyncValidationOptions { validateFn: (value: any) => Promise<boolean>; debounceMs?: number; } export function useAsyncValidation( fieldName: string, options: AsyncValidationOptions ) { const { validateFn, debounceMs = 500 } = options; const { watch, setError, clearErrors } = useFormContext(); const value = watch(fieldName); const debouncedValidation = React.useMemo( () => debounce(async (value: any) => { try { const isValid = await validateFn(value); if (!isValid) { setError(fieldName, { type: 'async', message: 'Valor inválido', }); } else { clearErrors(fieldName); } } catch (error) { setError(fieldName, { type: 'async', message: error.message, }); } }, debounceMs), [fieldName, validateFn, debounceMs] ); React.useEffect(() => { if (value) { debouncedValidation(value); } return () => { debouncedValidation.cancel(); }; }, [value, debouncedValidation]); }
Advanced UX Patterns
1. Progressive Visual Feedback
// src/components/ProgressiveFeedback/index.tsx import * as React from 'react'; import { useFormContext } from 'react-hook-form'; import { motion } from 'framer-motion'; export function ProgressiveFeedback() { const { formState } = useFormContext(); const { isValid, errors, dirtyFields } = formState; const totalFields = Object.keys(dirtyFields).length; const completedFields = totalFields - Object.keys(errors).length; const progress = totalFields ? (completedFields / totalFields) * 100 : 0; return ( <div className="space-y-2"> <div className="flex justify-between text-sm"> <span>Progresso do formulário</span> <span>{Math.round(progress)}%</span> </div> <div className="h-2 bg-gray-200 rounded-full overflow-hidden"> <motion.div className="h-full bg-primary" initial= animate= transition= /> </div> {isValid && ( <motion.p initial= animate= className="text-green-500 text-sm" > ✓ Todos os campos estão preenchidos corretamente! </motion.p> )} </div> ); }
2. Auto-Save
// src/hooks/useAutoSave.ts import * as React from 'react'; import { useFormContext } from 'react-hook-form'; import { debounce } from 'lodash'; interface AutoSaveOptions { onSave: (data: any) => Promise<void>; debounceMs?: number; enabled?: boolean; } export function useAutoSave(options: AutoSaveOptions) { const { onSave, debounceMs = 1000, enabled = true, } = options; const { watch, formState } = useFormContext(); const { isDirty } = formState; const formData = watch(); const debouncedSave = React.useMemo( () => debounce(async (data: any) => { try { await onSave(data); } catch (error) { console.error('Erro ao salvar:', error); } }, debounceMs), [onSave, debounceMs] ); React.useEffect(() => { if (enabled && isDirty) { debouncedSave(formData); } return () => { debouncedSave.cancel(); }; }, [formData, enabled, isDirty, debouncedSave]); }
3. Keyboard Navigation
// src/hooks/useKeyboardNavigation.ts import * as React from 'react'; export function useKeyboardNavigation() { const handleKeyPress = React.useCallback((event: KeyboardEvent) => { const activeElement = document.activeElement; if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); const form = activeElement?.closest('form'); if (!form) return; const inputs = Array.from( form.querySelectorAll( 'input:not([type="hidden"]), select, textarea' ) ); const currentIndex = inputs.indexOf(activeElement as Element); const nextInput = inputs[currentIndex + 1]; if (nextInput) { (nextInput as HTMLElement).focus(); } } }, []); React.useEffect(() => { document.addEventListener('keydown', handleKeyPress); return () => { document.removeEventListener('keydown', handleKeyPress); }; }, [handleKeyPress]); }
Implementation Best Practices
1. Organization of Validations
// src/validations/form-schemas.ts import { z } from 'zod'; export const addressSchema = z.object({ street: z.string().min(5, 'Endereço muito curto'), number: z.string().min(1, 'Número é obrigatório'), complement: z.string().optional(), neighborhood: z.string().min(2, 'Bairro muito curto'), city: z.string().min(2, 'Cidade muito curta'), state: z.string().length(2, 'Use a sigla do estado'), zipCode: z.string().regex(/^\d{8}$/, 'CEP inválido'), }); export const contactSchema = z.object({ email: z.string().email('Email inválido'), phone: z.string().regex(/^\d{10,11}$/, 'Telefone inválido'), preferredContact: z.enum(['email', 'phone']), }); export const completeFormSchema = z.object({ personal: personalSchema, address: addressSchema, contact: contactSchema, });
2. Error Handling
// src/components/ErrorBoundary/index.tsx import * as React from 'react'; import { AlertTriangle } from 'lucide-react'; interface ErrorBoundaryProps { children: React.ReactNode; fallback?: React.ReactNode; } interface ErrorBoundaryState { hasError: boolean; error?: Error; } export class ErrorBoundary extends React.Component< ErrorBoundaryProps, ErrorBoundaryState > { constructor(props: ErrorBoundaryProps) { super(props); this.state = { hasError: false }; } static getDerivedStateFromError(error: Error) { return { hasError: true, error }; } render() { if (this.state.hasError) { return ( this.props.fallback || ( <div className="p-4 border border-red-200 rounded-md bg-red-50"> <div className="flex items-center space-x-2 text-red-600"> <AlertTriangle className="h-5 w-5" /> <h3 className="font-medium"> Algo deu errado </h3> </div> <p className="mt-2 text-sm text-red-500"> Por favor, tente novamente mais tarde ou contate o suporte. </p> </div> ) ); } return this.props.children; } }
Implementation Checklist
Before Development
- Map all required fields
- Define validation rules
- Plan filling flow
- Identify interdependent fields
During Development
- Implement synchronous validations
- Add asynchronous validations
- Configure visual feedback
- Test different scenarios
Post-Development
- Check accessibility
- Test on different devices
- Validate performance
- Document special behaviors
Conclusion
Creating complex forms with React Hook Form requires a balance between functionality and user experience. Best practices include:
- Immediate Feedback: Real-time validation and clear messaging
- Accessibility: Support keyboard navigation and screen readers
- Performance: Optimized validation and efficient rendering
- Usability: Intuitive interface and error prevention
- Maintainability: Organized and well-documented code
Next Steps
- Implement the examples in your project
- Adapt the defaults to your needs
- Collect user feedback
- Iterate and continually improve
Are you developing complex forms? Share your experiences and questions in the comments below!
Also read
- Modern Design Systems: Building Scalable Component Libraries with React and TypeScript
- Internationalization Good Practices (i18n) in React and Next.js in 2025
- Digital Accessibility UX
- Accessibility in Mobile Applications
- User-Centered Design: How to Choose to Scale
- Interaction Design: How to Choose with Checklist
