Saltar para o conteúdo
Formulários com React Hook Form e Zod: Validação Type-Safe
React

Formulários com React Hook Form e Zod: Validação Type-Safe

20 de julho de 2024·Paulo de Paula

React Hook Form (RHF) usa inputs não-controlados internamente — isso significa que digitar não provoca re-render. Zod valida os dados com a mesma schema que define os tipos TypeScript. Juntos formam a melhor DX de formulários no ecossistema React.

Por que não useState para formulários?

// ❌ Cada tecla digitada re-renderiza o componente inteiro
function Form() {
  const [name, setName] = useState('');
  const [email, setEmail] = useState('');
  // ... mais campos = mais re-renders
  return <input value={name} onChange={e => setName(e.target.value)} />;
}

// ✅ RHF: re-render apenas na submissão ou em erros
function Form() {
  const { register, handleSubmit } = useForm();
  return <input {...register('name')} />;
}

Definindo a schema com Zod

import { z } from 'zod';

const schema = z.object({
  nome: z
    .string()
    .min(2, { message: 'Nome deve ter pelo menos 2 caracteres' })
    .max(100, { message: 'Nome muito longo' }),

  email: z
    .string()
    .email({ message: 'E-mail inválido' })
    .toLowerCase(),

  senha: z
    .string()
    .min(8, { message: 'Senha deve ter no mínimo 8 caracteres' })
    .regex(/[A-Z]/, { message: 'Deve conter pelo menos uma letra maiúscula' })
    .regex(/[0-9]/, { message: 'Deve conter pelo menos um número' }),

  confirmarSenha: z.string(),

  idade: z
    .number({ invalid_type_error: 'Informe um número' })
    .int()
    .min(18, { message: 'Você deve ter pelo menos 18 anos' })
    .max(120),
}).refine(data => data.senha === data.confirmarSenha, {
  message: 'As senhas não coincidem',
  path: ['confirmarSenha'],
});

// Tipo inferido automaticamente — sem duplicação
type FormData = z.infer<typeof schema>;

Conectando RHF e Zod

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';

function CadastroForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
    setError,
    reset,
  } = useForm<FormData>({
    resolver: zodResolver(schema),
    defaultValues: {
      nome: '',
      email: '',
      senha: '',
      confirmarSenha: '',
    },
  });

  const onSubmit = async (data: FormData) => {
    try {
      await api.post('/usuarios', data);
      reset();
      toast.success('Cadastro realizado!');
    } catch (err) {
      if (err.status === 409) {
        // Erro do servidor mapeado para campo específico
        setError('email', { message: 'E-mail já cadastrado' });
      }
    }
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)} noValidate>
      <div>
        <input {...register('nome')} placeholder="Nome completo" />
        {errors.nome && <span className="error">{errors.nome.message}</span>}
      </div>

      <div>
        <input {...register('email')} type="email" placeholder="E-mail" />
        {errors.email && <span className="error">{errors.email.message}</span>}
      </div>

      <div>
        <input {...register('senha')} type="password" placeholder="Senha" />
        {errors.senha && <span className="error">{errors.senha.message}</span>}
      </div>

      <div>
        <input {...register('confirmarSenha')} type="password" placeholder="Confirmar senha" />
        {errors.confirmarSenha && (
          <span className="error">{errors.confirmarSenha.message}</span>
        )}
      </div>

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Cadastrando...' : 'Cadastrar'}
      </button>
    </form>
  );
}

Validação assíncrona

const schema = z.object({
  username: z
    .string()
    .min(3)
    .refine(
      async (username) => {
        const { available } = await api.get(`/check-username?u=${username}`);
        return available;
      },
      { message: 'Usuário já em uso' }
    ),
});

// Importante: mode: 'onChange' para validar enquanto digita
const { register } = useForm({
  resolver: zodResolver(schema),
  mode: 'onChange',
});

Arrays de campos com useFieldArray

const schema = z.object({
  empresa: z.string(),
  contatos: z.array(z.object({
    nome: z.string().min(1),
    telefone: z.string().regex(/^\d{10,11}$/, 'Telefone inválido'),
  })).min(1, 'Adicione pelo menos um contato'),
});

function EmpresaForm() {
  const { control, register, formState: { errors } } = useForm({
    resolver: zodResolver(schema),
    defaultValues: { empresa: '', contatos: [{ nome: '', telefone: '' }] },
  });

  const { fields, append, remove } = useFieldArray({
    control,
    name: 'contatos',
  });

  return (
    <form>
      <input {...register('empresa')} placeholder="Nome da empresa" />

      {fields.map((field, index) => (
        <div key={field.id}>
          <input
            {...register(`contatos.${index}.nome`)}
            placeholder="Nome do contato"
          />
          {errors.contatos?.[index]?.nome && (
            <span>{errors.contatos[index].nome.message}</span>
          )}

          <input
            {...register(`contatos.${index}.telefone`)}
            placeholder="Telefone"
          />

          <button type="button" onClick={() => remove(index)}>
            Remover
          </button>
        </div>
      ))}

      <button type="button" onClick={() => append({ nome: '', telefone: '' })}>
        + Adicionar contato
      </button>
    </form>
  );
}

Controller para componentes controlados (Select, DatePicker)

import { Controller } from 'react-hook-form';
import DatePicker from 'react-datepicker';

function EventForm() {
  const { control, handleSubmit } = useForm<{ data: Date; categoria: string }>();

  return (
    <form onSubmit={handleSubmit(console.log)}>
      <Controller
        name="data"
        control={control}
        render={({ field }) => (
          <DatePicker
            selected={field.value}
            onChange={field.onChange}
            dateFormat="dd/MM/yyyy"
          />
        )}
      />

      <Controller
        name="categoria"
        control={control}
        render={({ field }) => (
          <Select
            options={categorias}
            value={categorias.find(c => c.value === field.value)}
            onChange={opt => field.onChange(opt?.value)}
          />
        )}
      />
    </form>
  );
}

Observando valores com watch

const { watch, register } = useForm();
const plano = watch('plano'); // re-render apenas quando 'plano' muda

// Mostrar campos condicionais
{plano === 'empresarial' && (
  <input {...register('cnpj')} placeholder="CNPJ" />
)}

A combinação RHF + Zod elimina a maior fonte de bugs em formulários: divergência entre a validação do schema e os tipos TypeScript. A schema define as regras uma única vez; os tipos derivam dela automaticamente.