Boas Práticas de UX para Formulários Complexos com React Hook Form
Em 2025, formulários web continuam sendo um dos principais pontos de interação entre usuários e aplicações. Com o React Hook Form evoluindo significativamente, vamos explorar as melhores práticas para criar formulários complexos que não apenas funcionem bem, mas também proporcionem uma experiência excepcional ao usuário.
Fundamentos de UX em Formulários
Princípios Essenciais
- Feedback Imediato: Validação em tempo real
- Prevenção de Erros: Guiar o usuário antes do erro acontecer
- Recuperação de Erros: Mensagens claras e ações corretivas
- Progressão Natural: Fluxo lógico de preenchimento
- Acessibilidade: Suporte a diferentes necessidades
Implementação com React Hook Form
Setup Inicial com 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(),
}),
});
Componente de Formulário Base
// 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>
);
}
Campos Inteligentes com 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>
);
}
Formulário Multi-etapas
// 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={{ width: '0%' }}
animate={{
width: `${((currentStep + 1) / steps.length) * 100}%`,
}}
transition={{ duration: 0.3 }}
/>
</div>
</div>
{/* Form Steps */}
<AnimatePresence mode="wait">
<motion.div
key={currentStep}
initial={{ opacity: 0, x: 50 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -50 }}
>
<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>
);
}
Validação Assíncrona
// 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]);
}
Padrões de UX Avançados
1. Feedback Visual Progressivo
// 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={{ width: 0 }}
animate={{ width: `${progress}%` }}
transition={{ duration: 0.5 }}
/>
</div>
{isValid && (
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="text-green-500 text-sm"
>
✓ Todos os campos estão preenchidos corretamente!
</motion.p>
)}
</div>
);
}
2. Salvamento Automático
// 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. Navegação por Teclado
// 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]);
}
Melhores Práticas de Implementação
1. Organização de Validações
// 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. Tratamento de Erros
// 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;
}
}
Checklist de Implementação
Antes do Desenvolvimento
- Mapear todos os campos necessários
- Definir regras de validação
- Planejar fluxo de preenchimento
- Identificar campos interdependentes
Durante o Desenvolvimento
- Implementar validações síncronas
- Adicionar validações assíncronas
- Configurar feedback visual
- Testar diferentes cenários
Pós-Desenvolvimento
- Verificar acessibilidade
- Testar em diferentes dispositivos
- Validar performance
- Documentar comportamentos especiais
Conclusão
A criação de formulários complexos com React Hook Form requer um equilíbrio entre funcionalidade e experiência do usuário. As melhores práticas incluem:
- Feedback Imediato: Validação em tempo real e mensagens claras
- Acessibilidade: Suporte a navegação por teclado e leitores de tela
- Performance: Validação otimizada e renderização eficiente
- Usabilidade: Interface intuitiva e prevenção de erros
- Manutenibilidade: Código organizado e bem documentado
Próximos Passos
- Implemente os exemplos em seu projeto
- Adapte os padrões às suas necessidades
- Colete feedback dos usuários
- Itere e melhore continuamente
Está desenvolvendo formulários complexos? Compartilhe suas experiências e dúvidas nos comentários abaixo!