🚀 Oferta especial: 60% OFF no CrazyStack - Últimas vagas!Garantir vaga →
Voltar ao Curso
MÓDULO 3
AULA 7

React Hook Form + Zod

Formulários performáticos com React Hook Form e validação type-safe com Zod, organizados usando Atomic Design.

60 min
Formulários
Validação
📝 Formulários Modernos

React Hook Form + Zod é a combinação perfeita para formulários performáticos, type-safe e com excelente experiência do usuário.

React Hook Form

  • Performance: Mínimos re-renders
  • Uncontrolled: Não usa state para valores
  • Validação: Integração com Zod
  • DevTools: Debug avançado
  • Bundle size: Muito leve

Zod

  • Type-safe: TypeScript automático
  • Runtime: Validação em tempo real
  • Composable: Schemas reutilizáveis
  • Mensagens: Erros customizáveis
  • Transform: Sanitização de dados
Instalação e Setup

Instalar dependências

npm install react-hook-form @hookform/resolvers zod
npm install @hookform/devtools  # opcional para debug

src/lib/validations.ts

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')
  },
}
Atoms para Formulários

src/components/atoms/FormField/FormField.tsx

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'

src/components/atoms/CurrencyInput/CurrencyInput.tsx

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'

src/components/atoms/PhoneInput/PhoneInput.tsx

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'
Molecules - Formulários Complexos

src/components/molecules/LoginForm/LoginForm.tsx

'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>
  )
}

src/components/molecules/ProductForm/ProductForm.tsx

'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>
  )
}
Hook Personalizado para Formulários

src/hooks/use-form-persistence.ts

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 }
}

src/hooks/use-form-validation.ts

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,
  }
}
🎯 Exercício Prático

Implemente formulários com React Hook Form + Zod:

1. Configurar dependências

npm install react-hook-form @hookform/resolvers zod
npm install @hookform/devtools  # opcional

2. Criar schemas de validação

Implemente schemas Zod para:

  • • Login com email
  • • Perfil do usuário
  • • Cadastro de produto
  • • Endereço do restaurante

3. Implementar componentes de formulário

Crie atoms e molecules para formulários:

  • • FormField com validação visual
  • • CurrencyInput formatado
  • • PhoneInput com máscara
  • • Formulário de produto completo

💡 Dicas de formulários:

  • • Use uncontrolled components para melhor performance
  • • Implemente validação em tempo real apenas quando necessário
  • • Persista dados importantes no localStorage
  • • Sempre forneça feedback visual claro para o usuário