Formulários performáticos com React Hook Form e validação type-safe com Zod, organizados usando Atomic Design.
React Hook Form + Zod é a combinação perfeita para formulários performáticos, type-safe e com excelente experiência do usuário.
npm install react-hook-form @hookform/resolvers zod npm install @hookform/devtools # opcional para debug
import { z } from 'zod' // Schemas base reutilizáveis export const emailSchema = z .string() .min(1, 'Email é obrigatório') .email('Email inválido') export const passwordSchema = z .string() .min(8, 'Senha deve ter pelo menos 8 caracteres') .regex(/[A-Z]/, 'Senha deve ter pelo menos uma letra maiúscula') .regex(/[a-z]/, 'Senha deve ter pelo menos uma letra minúscula') .regex(/[0-9]/, 'Senha deve ter pelo menos um número') export const phoneSchema = z .string() .regex(/^(d{2}) d{4,5}-d{4}$/, 'Telefone inválido') .optional() export const cepSchema = z .string() .regex(/^d{5}-d{3}$/, 'CEP inválido') // Schema de login export const loginSchema = z.object({ email: emailSchema, }) export type LoginFormData = z.infer<typeof loginSchema> // Schema de perfil do usuário export const userProfileSchema = z.object({ name: z.string().min(2, 'Nome deve ter pelo menos 2 caracteres'), email: emailSchema, phone: phoneSchema, }) export type UserProfileFormData = z.infer<typeof userProfileSchema> // Schema de restaurante export const restaurantSchema = z.object({ name: z.string().min(2, 'Nome do restaurante é obrigatório'), description: z.string().optional(), address: z.object({ street: z.string().min(5, 'Endereço é obrigatório'), number: z.string().min(1, 'Número é obrigatório'), complement: z.string().optional(), neighborhood: z.string().min(2, 'Bairro é obrigatório'), city: z.string().min(2, 'Cidade é obrigatória'), state: z.string().length(2, 'Estado deve ter 2 caracteres'), cep: cepSchema, }), }) export type RestaurantFormData = z.infer<typeof restaurantSchema> // Schema de produto export const productSchema = z.object({ name: z.string().min(2, 'Nome do produto é obrigatório'), description: z.string().optional(), priceInCents: z .number() .min(1, 'Preço deve ser maior que zero') .transform((val) => Math.round(val * 100)), // Converter para centavos category: z.string().min(1, 'Categoria é obrigatória'), available: z.boolean().default(true), }) export type ProductFormData = z.infer<typeof productSchema> // Utilitários para formatação export const formatters = { currency: (value: string) => { const numericValue = value.replace(/D/g, '') const formattedValue = (parseInt(numericValue) / 100).toFixed(2) return `R$ ${formattedValue.replace('.', ',')}` }, phone: (value: string) => { const numericValue = value.replace(/D/g, '') if (numericValue.length <= 10) { return numericValue.replace(/(d{2})(d{4})(d{4})/, '($1) $2-$3') } return numericValue.replace(/(d{2})(d{5})(d{4})/, '($1) $2-$3') }, cep: (value: string) => { const numericValue = value.replace(/D/g, '') return numericValue.replace(/(d{5})(d{3})/, '$1-$2') }, }
import { forwardRef } from 'react' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { cn } from '@/lib/utils' interface FormFieldProps extends React.InputHTMLAttributes<HTMLInputElement> { label: string error?: string description?: string required?: boolean } export const FormField = forwardRef<HTMLInputElement, FormFieldProps>( ({ label, error, description, required, className, id, ...props }, ref) => { const fieldId = id || label.toLowerCase().replace(/s+/g, '-') return ( <div className="space-y-2"> <Label htmlFor={fieldId} className="text-sm font-medium"> {label} {required && <span className="text-red-500 ml-1">*</span>} </Label> <Input id={fieldId} ref={ref} className={cn( error && 'border-red-500 focus:border-red-500', className )} aria-invalid={!!error} aria-describedby={ error ? `${fieldId}-error` : description ? `${fieldId}-description` : undefined } {...props} /> {description && !error && ( <p id={`${fieldId}-description`} className="text-xs text-muted-foreground"> {description} </p> )} {error && ( <p id={`${fieldId}-error`} className="text-xs text-red-500 flex items-center"> <AlertCircle className="h-3 w-3 mr-1" /> {error} </p> )} </div> ) } ) FormField.displayName = 'FormField'
import { forwardRef, useState } from 'react' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { cn } from '@/lib/utils' interface CurrencyInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange'> { label: string error?: string onValueChange?: (value: number) => void } export const CurrencyInput = forwardRef<HTMLInputElement, CurrencyInputProps>( ({ label, error, onValueChange, className, ...props }, ref) => { const [displayValue, setDisplayValue] = useState('') const formatCurrency = (value: string) => { // Remove tudo que não é dígito const numericValue = value.replace(/D/g, '') // Converte para número (em centavos) const numberValue = parseInt(numericValue) || 0 // Formata para exibição const formatted = (numberValue / 100).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL', }) return { formatted, numberValue } } const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { const { formatted, numberValue } = formatCurrency(e.target.value) setDisplayValue(formatted) onValueChange?.(numberValue) } return ( <div className="space-y-2"> <Label className="text-sm font-medium">{label}</Label> <Input ref={ref} value={displayValue} onChange={handleChange} placeholder="R$ 0,00" className={cn( error && 'border-red-500 focus:border-red-500', className )} {...props} /> {error && ( <p className="text-xs text-red-500">{error}</p> )} </div> ) } ) CurrencyInput.displayName = 'CurrencyInput'
import { forwardRef, useState } from 'react' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { cn } from '@/lib/utils' interface PhoneInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange'> { label: string error?: string onValueChange?: (value: string) => void } export const PhoneInput = forwardRef<HTMLInputElement, PhoneInputProps>( ({ label, error, onValueChange, className, ...props }, ref) => { const [displayValue, setDisplayValue] = useState('') const formatPhone = (value: string) => { const numericValue = value.replace(/D/g, '') if (numericValue.length <= 10) { return numericValue.replace(/(d{2})(d{4})(d{4})/, '($1) $2-$3') } return numericValue.replace(/(d{2})(d{5})(d{4})/, '($1) $2-$3') } const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { const formatted = formatPhone(e.target.value) setDisplayValue(formatted) onValueChange?.(formatted) } return ( <div className="space-y-2"> <Label className="text-sm font-medium">{label}</Label> <Input ref={ref} value={displayValue} onChange={handleChange} placeholder="(11) 99999-9999" maxLength={15} className={cn( error && 'border-red-500 focus:border-red-500', className )} {...props} /> {error && ( <p className="text-xs text-red-500">{error}</p> )} </div> ) } ) PhoneInput.displayName = 'PhoneInput'
'use client' import { useState } from 'react' import { useForm } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { Button } from '@/components/ui/button' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { FormField } from '@/components/atoms/FormField/FormField' import { useAuth } from '@/hooks/use-auth' import { loginSchema, type LoginFormData } from '@/lib/validations' import { Mail, Loader2 } from 'lucide-react' export function LoginForm() { const [isSubmitted, setIsSubmitted] = useState(false) const { sendMagicLink, isLoadingSendMagicLink } = useAuth() const { register, handleSubmit, formState: { errors, isSubmitting }, reset, } = useForm<LoginFormData>({ resolver: zodResolver(loginSchema), defaultValues: { email: '', }, }) const onSubmit = async (data: LoginFormData) => { try { await sendMagicLink(data.email) setIsSubmitted(true) } catch (error) { // Erro já tratado pelo hook console.error('Erro no login:', error) } } const handleTryAgain = () => { setIsSubmitted(false) reset() } if (isSubmitted) { return ( <Card className="w-full max-w-md mx-auto"> <CardHeader className="text-center"> <div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4"> <Mail className="w-8 h-8 text-green-600" /> </div> <CardTitle>Verifique seu email</CardTitle> </CardHeader> <CardContent className="text-center space-y-4"> <p className="text-muted-foreground"> Enviamos um magic link para seu email. Clique no link para entrar no dashboard do Restaurantix. </p> <Button variant="outline" onClick={handleTryAgain} className="w-full" > Enviar novamente </Button> </CardContent> </Card> ) } return ( <Card className="w-full max-w-md mx-auto"> <CardHeader className="text-center"> <CardTitle>Entrar no Restaurantix</CardTitle> <p className="text-muted-foreground"> Digite seu email para receber um magic link </p> </CardHeader> <CardContent> <form onSubmit={handleSubmit(onSubmit)} className="space-y-6"> <FormField label="Email" type="email" placeholder="seu@email.com" error={errors.email?.message} required {...register('email')} /> <Button type="submit" className="w-full" disabled={isSubmitting || isLoadingSendMagicLink} > {isSubmitting || isLoadingSendMagicLink ? ( <> <Loader2 className="w-4 h-4 mr-2 animate-spin" /> Enviando... </> ) : ( <> <Mail className="w-4 h-4 mr-2" /> Enviar Magic Link </> )} </Button> </form> </CardContent> </Card> ) }
'use client' import { useForm, Controller } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { Button } from '@/components/ui/button' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { FormField } from '@/components/atoms/FormField/FormField' import { CurrencyInput } from '@/components/atoms/CurrencyInput/CurrencyInput' import { Switch } from '@/components/ui/switch' import { Label } from '@/components/ui/label' import { Textarea } from '@/components/ui/textarea' import { productSchema, type ProductFormData } from '@/lib/validations' import { Save, Loader2 } from 'lucide-react' interface ProductFormProps { initialData?: Partial<ProductFormData> onSubmit: (data: ProductFormData) => Promise<void> isLoading?: boolean } export function ProductForm({ initialData, onSubmit, isLoading }: ProductFormProps) { const { register, handleSubmit, control, formState: { errors, isSubmitting }, } = useForm<ProductFormData>({ resolver: zodResolver(productSchema), defaultValues: { name: '', description: '', priceInCents: 0, category: '', available: true, ...initialData, }, }) const handleFormSubmit = async (data: ProductFormData) => { try { await onSubmit(data) } catch (error) { console.error('Erro ao salvar produto:', error) } } return ( <Card> <CardHeader> <CardTitle> {initialData ? 'Editar Produto' : 'Novo Produto'} </CardTitle> </CardHeader> <CardContent> <form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-6"> <div className="grid md:grid-cols-2 gap-4"> <FormField label="Nome do Produto" placeholder="Ex: Pizza Margherita" error={errors.name?.message} required {...register('name')} /> <FormField label="Categoria" placeholder="Ex: Pizzas" error={errors.category?.message} required {...register('category')} /> </div> <div> <Label htmlFor="description" className="text-sm font-medium"> Descrição </Label> <Textarea id="description" placeholder="Descreva o produto..." className="mt-2" {...register('description')} /> {errors.description && ( <p className="text-xs text-red-500 mt-1"> {errors.description.message} </p> )} </div> <div className="grid md:grid-cols-2 gap-4"> <Controller name="priceInCents" control={control} render={({ field }) => ( <CurrencyInput label="Preço" error={errors.priceInCents?.message} onValueChange={field.onChange} /> )} /> <div className="flex items-center space-x-2 pt-8"> <Controller name="available" control={control} render={({ field }) => ( <Switch id="available" checked={field.value} onCheckedChange={field.onChange} /> )} /> <Label htmlFor="available">Produto disponível</Label> </div> </div> <div className="flex justify-end space-x-4"> <Button type="button" variant="outline"> Cancelar </Button> <Button type="submit" disabled={isSubmitting || isLoading} > {isSubmitting || isLoading ? ( <> <Loader2 className="w-4 h-4 mr-2 animate-spin" /> Salvando... </> ) : ( <> <Save className="w-4 h-4 mr-2" /> Salvar Produto </> )} </Button> </div> </form> </CardContent> </Card> ) }
import { useEffect } from 'react' import { UseFormReturn } from 'react-hook-form' interface UseFormPersistenceOptions { key: string exclude?: string[] include?: string[] } export function useFormPersistence<T extends Record<string, any>>( form: UseFormReturn<T>, options: UseFormPersistenceOptions ) { const { key, exclude = [], include } = options // Salvar dados no localStorage useEffect(() => { const subscription = form.watch((data) => { if (typeof window !== 'undefined') { const filteredData = Object.entries(data).reduce((acc, [field, value]) => { const shouldInclude = include ? include.includes(field) : !exclude.includes(field) if (shouldInclude && value !== undefined && value !== '') { acc[field] = value } return acc }, {} as Record<string, any>) if (Object.keys(filteredData).length > 0) { localStorage.setItem(`form-${key}`, JSON.stringify(filteredData)) } } }) return () => subscription.unsubscribe() }, [form, key, exclude, include]) // Carregar dados do localStorage useEffect(() => { if (typeof window !== 'undefined') { const savedData = localStorage.getItem(`form-${key}`) if (savedData) { try { const parsedData = JSON.parse(savedData) Object.entries(parsedData).forEach(([field, value]) => { form.setValue(field as keyof T, value) }) } catch (error) { console.error('Erro ao carregar dados do formulário:', error) } } } }, [form, key]) // Limpar dados salvos const clearSavedData = () => { if (typeof window !== 'undefined') { localStorage.removeItem(`form-${key}`) } } return { clearSavedData } }
import { useState, useCallback } from 'react' import { ZodSchema, ZodError } from 'zod' export function useFormValidation<T>(schema: ZodSchema<T>) { const [errors, setErrors] = useState<Record<string, string>>({}) const validateField = useCallback( (field: keyof T, value: any) => { try { // Validar apenas o campo específico const fieldSchema = schema.shape[field as string] if (fieldSchema) { fieldSchema.parse(value) setErrors(prev => { const newErrors = { ...prev } delete newErrors[field as string] return newErrors }) } } catch (error) { if (error instanceof ZodError) { setErrors(prev => ({ ...prev, [field as string]: error.errors[0]?.message || 'Erro de validação' })) } } }, [schema] ) const validateAll = useCallback( (data: T) => { try { schema.parse(data) setErrors({}) return true } catch (error) { if (error instanceof ZodError) { const newErrors: Record<string, string> = {} error.errors.forEach((err) => { if (err.path.length > 0) { newErrors[err.path.join('.')] = err.message } }) setErrors(newErrors) } return false } }, [schema] ) const clearErrors = useCallback(() => { setErrors({}) }, []) return { errors, validateField, validateAll, clearErrors, hasErrors: Object.keys(errors).length > 0, } }
Implemente formulários com React Hook Form + Zod:
npm install react-hook-form @hookform/resolvers zod npm install @hookform/devtools # opcional
Implemente schemas Zod para:
Crie atoms e molecules para formulários: