Como usar React com Zod: Guia Completo
Introdução
Validação de dados é uma daquelas responsabilidades que todo desenvolvedor reconhece como essencial, mas que frequentemente acaba implementada de forma inconsistente. Quem nunca escreveu um if (!email.includes('@')) solto em algum handler e sentiu aquele desconforto silencioso?
O problema real não é a falta de validação. É a falta de um sistema de validação que seja previsível, reutilizável e integrado ao sistema de tipos do TypeScript. É exatamente aqui que a combinação React com Zod se torna poderosa.
Zod é uma biblioteca de declaração e validação de schemas com inferência de tipos nativa para TypeScript. Em vez de definir um tipo e depois escrever validações separadas (que podem divergir silenciosamente), você declara o schema uma única vez e extrai tanto a validação em runtime quanto o tipo estático a partir dele. O resultado: uma única fonte de verdade para a forma dos seus dados.
Neste guia, vamos desde a configuração inicial até padrões avançados que uso em projetos de produção. Se você já trabalha com React e TypeScript há pelo menos dois anos, vai se sentir em casa.
Por que usar React com Zod juntos
O problema da validação duplicada
Em projetos React com TypeScript, é comum encontrar este cenário: você define uma interface para os dados do formulário, depois escreve validações manuais no onSubmit, e ainda precisa validar novamente no backend. Três lugares diferentes descrevendo a mesma regra de negócio. Quando alguém muda o tamanho mínimo da senha em um lugar e esquece dos outros, nasce um bug silencioso.
Zod elimina essa duplicação. Você escreve o schema uma vez e usa z.infer para extrair o tipo TypeScript. Se o schema muda, o tipo muda junto, e o compilador aponta todos os lugares que precisam de ajuste.
Benefícios concretos da combinação
A integração React com Zod oferece vantagens que vão além da conveniência:
- Inferência de tipos automática: o tipo TypeScript é derivado do schema, não declarado separadamente.
- Mensagens de erro granulares: Zod permite personalizar mensagens por campo e por tipo de erro.
- Composição de schemas: você pode criar schemas base e estendê-los, ideal para formulários que compartilham campos comuns.
- Validação assíncrona: Zod suporta
refineassíncrono, útil para validar disponibilidade de username ou email. - Integração nativa com React Hook Form: a biblioteca
@hookform/resolverstem suporte direto para Zod.
Setup do projeto React com Zod
Instalação e configuração inicial
Assumindo que você já tem um projeto React com TypeScript (via Vite, Next.js ou CRA), instale as dependências necessárias:
// Terminal
// npm install zod react-hook-form @hookform/resolversEssas três bibliotecas formam a base. O react-hook-form cuida do gerenciamento de estado do formulário, o @hookform/resolvers faz a ponte entre o form e o Zod, e o Zod cuida da validação e tipagem.
A versão mínima recomendada do Zod é a 3.x, que trouxe melhorias significativas na inferência de tipos e na API de refinamento.
Estrutura de pastas recomendada
Para projetos de médio e grande porte, recomendo isolar os schemas em uma pasta própria:
// src/schemas/user.schema.ts
// src/schemas/product.schema.ts
// src/schemas/shared.schema.ts (schemas reutilizáveis)Essa separação permite reutilizar schemas entre formulários, páginas e até entre frontend e backend (em projetos monorepo). Se você utiliza Next.js com Server Actions, essa abordagem se torna ainda mais valiosa. Inclusive, abordamos Server Actions em detalhes no post Revolucione sua Aplicação React com Next.js 15 Server Actions, onde essa mesma estrutura de schemas compartilhados se aplica perfeitamente.
Exemplo prático: formulário de cadastro com validação completa
Vamos construir um formulário de cadastro de usuário real, com validações que você encontraria em produção.
Definindo o schema com Zod
// src/schemas/register.schema.ts
import { z } from 'zod';
export const registerSchema = z
.object({
name: z
.string()
.min(3, { message: 'O nome deve ter pelo menos 3 caracteres' })
.max(100, { message: 'O nome deve ter no máximo 100 caracteres' })
.trim(),
email: z
.string()
.email({ message: 'Informe um email válido' })
.toLowerCase(),
age: z
.number({
required_error: 'A idade é obrigatória',
invalid_type_error: 'A idade deve ser um número',
})
.int({ message: 'A idade deve ser um número inteiro' })
.min(18, { message: 'Você deve ter pelo menos 18 anos' })
.max(120, { message: 'Informe uma idade válida' }),
password: z
.string()
.min(8, { message: 'A senha deve ter pelo menos 8 caracteres' })
.regex(/[A-Z]/, {
message: 'A senha deve conter pelo menos uma letra maiúscula',
})
.regex(/[0-9]/, {
message: 'A senha deve conter pelo menos um número',
})
.regex(/[^A-Za-z0-9]/, {
message: 'A senha deve conter pelo menos um caractere especial',
}),
confirmPassword: z.string(),
role: z.enum(['developer', 'designer', 'manager'], {
errorMap: () => ({ message: 'Selecione um cargo válido' }),
}),
acceptTerms: z.literal(true, {
errorMap: () => ({
message: 'Você deve aceitar os termos de uso',
}),
}),
})
.refine((data) => data.password === data.confirmPassword, {
message: 'As senhas não coincidem',
path: ['confirmPassword'],
});
// O tipo é inferido automaticamente do schema
export type RegisterFormData = z.infer<typeof registerSchema>;Observe que RegisterFormData não foi escrito manualmente. Ele é derivado do schema. Se amanhã você adicionar um campo phone ao schema, o tipo reflete isso instantaneamente, e qualquer componente que use esse tipo acusará erro de compilação onde necessário.
Construindo o componente React
// src/components/RegisterForm.tsx
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { registerSchema, type RegisterFormData } from '../schemas/register.schema';
export function RegisterForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<RegisterFormData>({
resolver: zodResolver(registerSchema),
defaultValues: {
name: '',
email: '',
age: undefined,
password: '',
confirmPassword: '',
role: undefined,
acceptTerms: undefined,
},
});
async function onSubmit(data: RegisterFormData) {
// Neste ponto, "data" já está validado e tipado
// O TypeScript sabe exatamente a forma do objeto
console.log('Dados validados:', data);
try {
const response = await fetch('/api/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error('Falha no cadastro');
}
// Sucesso: redirecionar ou exibir mensagem
} catch (error) {
console.error('Erro ao cadastrar:', error);
}
}
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<div>
<label htmlFor="name">Nome completo</label>
<input id="name" type="text" {...register('name')} />
{errors.name && (
<span role="alert">{errors.name.message}</span>
)}
</div>
<div>
<label htmlFor="email">Email</label>
<input id="email" type="email" {...register('email')} />
{errors.email && (
<span role="alert">{errors.email.message}</span>
)}
</div>
<div>
<label htmlFor="age">Idade</label>
<input
id="age"
type="number"
{...register('age', { valueAsNumber: true })}
/>
{errors.age && (
<span role="alert">{errors.age.message}</span>
)}
</div>
<div>
<label htmlFor="password">Senha</label>
<input id="password" type="password" {...register('password')} />
{errors.password && (
<span role="alert">{errors.password.message}</span>
)}
</div>
<div>
<label htmlFor="confirmPassword">Confirmar senha</label>
<input
id="confirmPassword"
type="password"
{...register('confirmPassword')}
/>
{errors.confirmPassword && (
<span role="alert">{errors.confirmPassword.message}</span>
)}
</div>
<div>
<label htmlFor="role">Cargo</label>
<select id="role" {...register('role')}>
<option value="">Selecione</option>
<option value="developer">Desenvolvedor(a)</option>
<option value="designer">Designer</option>
<option value="manager">Gerente</option>
</select>
{errors.role && (
<span role="alert">{errors.role.message}</span>
)}
</div>
<div>
<label>
<input type="checkbox" {...register('acceptTerms')} />
Aceito os termos de uso
</label>
{errors.acceptTerms && (
<span role="alert">{errors.acceptTerms.message}</span>
)}
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Cadastrando...' : 'Cadastrar'}
</button>
</form>
);
}O ponto mais importante aqui: quando onSubmit é chamado, os dados já passaram por toda a validação do Zod. Você não precisa de nenhum if adicional dentro dessa função. O tipo RegisterFormData garante em tempo de compilação que todos os campos existem e estão no formato correto.
Exemplo prático: validação de resposta de API
Formulários são o caso de uso mais óbvio, mas Zod brilha igualmente na validação de dados que chegam de APIs externas. Dados de terceiros são imprevisíveis, e confiar cegamente no tipo declarado é uma armadilha.
// src/schemas/api-responses.schema.ts
import { z } from 'zod';
const productSchema = z.object({
id: z.string().uuid(),
title: z.string().min(1),
price: z.number().positive(),
category: z.enum(['electronics', 'clothing', 'books', 'home']),
inStock: z.boolean(),
tags: z.array(z.string()).default([]),
metadata: z
.object({
createdAt: z.string().datetime(),
updatedAt: z.string().datetime().optional(),
})
.optional(),
});
const paginatedResponseSchema = z.object({
data: z.array(productSchema),
pagination: z.object({
page: z.number().int().positive(),
perPage: z.number().int().positive(),
total: z.number().int().nonnegative(),
totalPages: z.number().int().nonnegative(),
}),
});
export type Product = z.infer<typeof productSchema>;
export type PaginatedProductResponse = z.infer<typeof paginatedResponseSchema>;
// Hook customizado com validação Zod
export function useProducts(page: number = 1) {
async function fetchProducts(): Promise<PaginatedProductResponse> {
const response = await fetch(`/api/products?page=${page}`);
const json: unknown = await response.json();
// safeParse não lança exceção: retorna um resultado tipado
const result = paginatedResponseSchema.safeParse(json);
if (!result.success) {
// Em desenvolvimento, logue os erros detalhados
console.error(
'Resposta da API fora do formato esperado:',
result.error.flatten()
);
throw new Error('Formato de resposta inválido');
}
return result.data;
}
// Retorne para uso com React Query, SWR, ou useEffect
return { fetchProducts };
}O uso de safeParse em vez de parse é uma decisão deliberada. Com safeParse, você tem controle total sobre como lidar com dados inválidos sem depender de try/catch. O método flatten() no objeto de erro organiza as mensagens de forma amigável, separando erros de campos específicos de erros gerais.
Boas práticas para React com Zod em produção
Componha schemas em vez de duplicar
Se você tem um formulário de criação e outro de edição que compartilham campos, use composição:
const baseUserSchema = z.object({
name: z.string().min(3),
email: z.string().email(),
});
const createUserSchema = baseUserSchema.extend({
password: z.string().min(8),
});
const updateUserSchema = baseUserSchema.partial().extend({
id: z.string().uuid(),
});O método .partial() torna todos os campos opcionais, exatamente o que você precisa para atualizações parciais (PATCH).
Centralize mensagens de erro
Para projetos com suporte a múltiplos idiomas ou que simplesmente precisam de consistência, crie funções auxiliares para mensagens:
const requiredString = (field: string) =>
z.string({
required_error: `${field} é obrigatório`,
}).min(1, { message: `${field} não pode estar vazio` });Valide variáveis de ambiente
Um caso de uso que muitos ignoram: Zod é excelente para validar variáveis de ambiente no startup da aplicação, garantindo que o projeto não suba com configurações faltantes.
Use transform para normalizar dados
Zod permite transformar dados durante a validação. Use para normalizar CPFs (removendo pontuação), converter strings de datas em objetos Date, ou padronizar formatos.
Evite schemas excessivamente complexos em um único arquivo
Se um schema tem mais de 50 linhas, provavelmente ele contém sub-schemas que podem ser extraídos e reutilizados. Schemas pequenos e compostos são mais fáceis de testar e manter.
Conclusão
A combinação de React com Zod resolve um problema estrutural que afeta a maioria dos projetos frontend: a desconexão entre tipos estáticos e validação em runtime. Com Zod, você pode validar dados em runtime e derivar tipos dos mesmos schemas. Use essa base nos formulários e nas respostas de API, com tratamento explícito dos erros e testes para entradas válidas e inválidas.
Guias Relacionados
Explore mais guias técnicos
Mais de 100 guias práticos de integração entre frameworks, ferramentas e plataformas.
Ver todos os guias