<h2>Introdução: Por Que Zod com React Hook Form?</h2>
<p>A validação de formulários é um dos maiores desafios na construção de aplicações web modernas. Precisamos validar dados no cliente para melhorar a experiência do usuário, mas também manter mensagens de erro claras e contextualizadas. Quando você combina <strong>Zod</strong> (uma biblioteca TypeScript-first de schema validation) com <strong>React Hook Form</strong> (um gerenciador de estado de formulário performático), você consegue criar validações robustas sem sacrificar performance ou legibilidade do código.</p>
<p>Neste artigo, vou guiá-lo além das validações básicas. Você vai aprender a construir schemas complexos com validações condicionais, refinar mensagens de erro para contextos específicos e criar uma experiência de usuário profissional. Este é o conhecimento que você precisa ter para trabalhar com formulários em produção.</p>
<h2>Fundamentos: Entendendo Zod e React Hook Form</h2>
<h3>O Que é Zod?</h3>
<p>Zod é uma biblioteca de validação de schemas TypeScript que permite definir validações de forma declarativa. Diferente de outras soluções, o Zod oferece inferência de tipos automática — quando você cria um schema, você ganha o tipo TypeScript correspondente gratuitamente. Isso elimina a duplicação de código entre validação e tipagem.</p>
<pre><code class="language-typescript">import { z } from 'zod';
// Define um schema simples
const usuarioSchema = z.object({
email: z.string().email('Email inválido'),
idade: z.number().min(18, 'Deve ter pelo menos 18 anos'),
nome: z.string().min(3, 'Nome deve ter no mínimo 3 caracteres'),
});
// TypeScript infere automaticamente este tipo:
type Usuario = z.infer<typeof usuarioSchema>;
// Equivalente a: { email: string; idade: number; nome: string }</code></pre>
<h3>Integrando Zod com React Hook Form</h3>
<p>React Hook Form é extremamente leve e focado em performance. Quando integrado com Zod via <code>zodResolver</code>, o Hook Form executa a validação usando seu schema Zod e popula os erros automaticamente. A integração é simples, mas poderosa.</p>
<pre><code class="language-typescript">import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
const MinhaForm = () => {
const {
register,
handleSubmit,
formState: { errors },
} = useForm({
resolver: zodResolver(usuarioSchema),
});
const onSubmit = (data) => console.log(data);
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email')} placeholder="Email" />
{errors.email && <p>{errors.email.message}</p>}
<input {...register('idade', { valueAsNumber: true })} type="number" />
{errors.idade && <p>{errors.idade.message}</p>}
<button type="submit">Enviar</button>
</form>
);
};</code></pre>
<h2>Schemas Complexos e Validações Avançadas</h2>
<h3>Validações Condicionais com <code>.refine()</code> e <code>.superRefine()</code></h3>
<p>Às vezes você precisa validar um campo baseado no valor de outro campo — por exemplo, confirmar que a senha de confirmação é igual à senha. Zod oferece <code>.refine()</code> para validações customizadas simples e <code>.superRefine()</code> para casos onde você precisa de controle total sobre os erros.</p>
<pre><code class="language-typescript">const senhaSchema = z.object({
senha: z.string().min(8, 'Senha deve ter no mínimo 8 caracteres'),
confirmaSenha: z.string(),
}).refine(
(data) => data.senha === data.confirmaSenha,
{
message: 'As senhas devem ser iguais',
path: ['confirmaSenha'], // Define em qual campo o erro aparecerá
}
);
// Uso com React Hook Form
const form = useForm({
resolver: zodResolver(senhaSchema),
});</code></pre>
<p>Para validações mais complexas que envolvem múltiplos campos com mensagens diferentes, use <code>.superRefine()</code>:</p>
<pre><code class="language-typescript">const pagamentoSchema = z.object({
tipo: z.enum(['cartao', 'boleto']),
numeroCartao: z.string().optional(),
agencia: z.string().optional(),
}).superRefine((data, ctx) => {
if (data.tipo === 'cartao' && !data.numeroCartao) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['numeroCartao'],
message: 'Número do cartão é obrigatório',
});
}
if (data.tipo === 'boleto' && !data.agencia) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['agencia'],
message: 'Agência é obrigatória para boleto',
});
}
});</code></pre>
<h3>Arrays e Objetos Aninhados</h3>
<p>Formulários reais frequentemente contêm listas de itens ou estruturas aninhadas. Zod trata isso elegantemente com <code>.array()</code> e schemas aninhados:</p>
<pre><code class="language-typescript">const formularioCidadeSchema = z.object({
cidade: z.string().min(1, 'Cidade obrigatória'),
endereco: z.object({
rua: z.string().min(1, 'Rua obrigatória'),
numero: z.number().positive('Número deve ser positivo'),
complemento: z.string().optional(),
}),
contatos: z.array(
z.object({
tipo: z.enum(['email', 'telefone']),
valor: z.string().min(1, 'Valor obrigatório'),
})
).min(1, 'Adicione pelo menos um contato'),
});
type FormularioCidade = z.infer<typeof formularioCidadeSchema>;</code></pre>
<p>Ao usar isso em React Hook Form com campos dinâmicos, você pode iterar sobre os campos:</p>
<pre><code class="language-typescript">import { useFieldArray, Controller } from 'react-hook-form';
const FormularioCidade = () => {
const {
register,
control,
handleSubmit,
formState: { errors },
} = useForm({
resolver: zodResolver(formularioCidadeSchema),
});
const { fields, append, remove } = useFieldArray({
control,
name: 'contatos',
});
return (
<form onSubmit={handleSubmit((data) => console.log(data))}>
<input {...register('cidade')} placeholder="Cidade" />
{errors.cidade && <p>{errors.cidade.message}</p>}
<fieldset>
<legend>Endereço</legend>
<input {...register('endereco.rua')} placeholder="Rua" />
{errors.endereco?.rua && <p>{errors.endereco.rua.message}</p>}
<input
{...register('endereco.numero', { valueAsNumber: true })}
type="number"
/>
{errors.endereco?.numero && <p>{errors.endereco.numero.message}</p>}
</fieldset>
<fieldset>
<legend>Contatos</legend>
{fields.map((field, index) => (
<div key={field.id}>
<select {...register(contatos.${index}.tipo)}>
<option value="email">Email</option>
<option value="telefone">Telefone</option>
</select>
<input
{...register(contatos.${index}.valor)}
placeholder="Valor"
/>
{errors.contatos?.[index]?.valor && (
<p>{errors.contatos[index]?.valor?.message}</p>
)}
<button type="button" onClick={() => remove(index)}>
Remover
</button>
</div>
))}
<button type="button" onClick={() => append({ tipo: 'email', valor: '' })}>
Adicionar Contato
</button>
</fieldset>
<button type="submit">Enviar</button>
</form>
);
};</code></pre>
<h2>Mensagens de Erro Customizadas</h2>
<h3>Localizando Mensagens (i18n)</h3>
<p>Em aplicações internacionais, você precisa oferecer mensagens de erro no idioma do usuário. Com Zod, você pode definir mensagens customizadas globalmente ou por schema:</p>
<pre><code class="language-typescript">import { z } from 'zod';
// Definir mensagens globais para o português
const zodPt = z;
// Ou criar schemas com mensagens customizadas
const loginSchema = z.object({
email: z
.string({ required_error: 'Email é obrigatório' })
.email('Insira um email válido'),
senha: z
.string({ required_error: 'Senha é obrigatória' })
.min(6, 'Senha deve ter no mínimo 6 caracteres')
.max(50, 'Senha não pode exceder 50 caracteres'),
});
// Para suportar múltiplos idiomas, crie uma função factory:
const criarSchemaAutenticacao = (idioma: 'pt' | 'en') => {
const mensagens = {
pt: {
emailObrigatorio: 'Email é obrigatório',
emailInvalido: 'Insira um email válido',
senhaObrigatoria: 'Senha é obrigatória',
senhaMinima: 'Senha deve ter no mínimo 6 caracteres',
},
en: {
emailObrigatorio: 'Email is required',
emailInvalido: 'Enter a valid email',
senhaObrigatoria: 'Password is required',
senhaMinima: 'Password must have at least 6 characters',
},
};
const msg = mensagens[idioma];
return z.object({
email: z
.string({ required_error: msg.emailObrigatorio })
.email(msg.emailInvalido),
senha: z
.string({ required_error: msg.senhaObrigatoria })
.min(6, msg.senhaMinima),
});
};</code></pre>
<h3>Exibindo Erros de Forma Amigável</h3>
<p>Não basta validar — você precisa comunicar os erros de forma clara. Crie componentes reutilizáveis que tratam erros com elegância:</p>
<pre><code class="language-typescript">import React from 'react';
import { FieldError } from 'react-hook-form';
interface InputComErroProps {
label: string;
erro?: FieldError;
registro: any;
tipo?: 'text' | 'email' | 'password' | 'number';
}
const InputComErro: React.FC<InputComErroProps> = ({
label,
erro,
registro,
tipo = 'text',
}) => {
return (
<div style={{ marginBottom: '1rem' }}>
<label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: 'bold' }}>
{label}
</label>
<input
{...registro}
type={tipo}
style={{
width: '100%',
padding: '0.5rem',
borderRadius: '4px',
border: 2px solid ${erro ? '#dc2626' : '#e5e7eb'},
fontSize: '1rem',
fontFamily: 'inherit',
}}
/>
{erro && (
<p style={{ color: '#dc2626', marginTop: '0.25rem', fontSize: '0.875rem' }}>
{erro.message}
</p>
)}
</div>
);
};
// Uso em um formulário
const FormularioSimples = () => {
const {
register,
handleSubmit,
formState: { errors },
} = useForm({
resolver: zodResolver(loginSchema),
});
return (
<form onSubmit={handleSubmit((data) => console.log(data))}>
<InputComErro
label="Email"
erro={errors.email}
registro={register('email')}
tipo="email"
/>
<InputComErro
label="Senha"
erro={errors.senha}
registro={register('senha')}
tipo="password"
/>
<button type="submit">Entrar</button>
</form>
);
};</code></pre>
<h3>Erros a Nível de Formulário</h3>
<p>Às vezes você precisa validar todo o formulário — por exemplo, verificar se um usuário existe no servidor. Use o callback <code>onError</code> do <code>handleSubmit</code>:</p>
<pre><code class="language-typescript">const FormularioComValidacaoServidor = () => {
const [erroGlobal, setErroGlobal] = React.useState('');
const {
register,
handleSubmit,
formState: { errors },
} = useForm({
resolver: zodResolver(loginSchema),
});
const onSubmit = async (data) => {
try {
setErroGlobal('');
const response = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!response.ok) {
if (response.status === 401) {
setErroGlobal('Email ou senha inválidos');
} else {
setErroGlobal('Erro ao conectar. Tente novamente.');
}
} else {
console.log('Login bem-sucedido');
}
} catch (error) {
setErroGlobal('Erro de conexão');
}
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
{erroGlobal && (
<div style={{ padding: '1rem', background: '#fee2e2', color: '#991b1b', borderRadius: '4px', marginBottom: '1rem' }}>
{erroGlobal}
</div>
)}
<InputComErro
label="Email"
erro={errors.email}
registro={register('email')}
tipo="email"
/>
<InputComErro
label="Senha"
erro={errors.senha}
registro={register('senha')}
tipo="password"
/>
<button type="submit">Entrar</button>
</form>
);
};</code></pre>
<h2>Exemplo Completo: Formulário de Cadastro Avançado</h2>
<p>Vamos unir tudo isso em um exemplo prático — um formulário de cadastro com validações complexas, campos dinâmicos e mensagens customizadas:</p>
<pre><code class="language-typescript">import React from 'react';
import { useForm, useFieldArray } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
// Schema com validações avançadas
const cadastroSchema = z.object({
nome: z
.string()
.min(3, 'Nome deve ter no mínimo 3 caracteres')
.max(100, 'Nome não pode exceder 100 caracteres'),
email: z
.string()
.email('Email inválido'),
dataNascimento: z
.string()
.refine(
(data) => {
const idade = new Date().getFullYear() - new Date(data).getFullYear();
return idade >= 18;
},
'Você deve ter no mínimo 18 anos'
),
plano: z.enum(['gratuito', 'profissional', 'empresarial']),
cartao: z.object({
numero: z.string().regex(/^\d{16}$/, 'Cartão deve ter 16 dígitos'),
mes: z.string().regex(/^\d{2}$/, 'Mês inválido'),
ano: z.string().regex(/^\d{4}$/, 'Ano inválido'),
cvv: z.string().regex(/^\d{3}$/, 'CVV deve ter 3 dígitos'),
}).optional(),
interesses: z
.array(z.string())
.min(1, 'Selecione pelo menos um interesse'),
termos: z
.boolean()
.refine((val) => val === true, 'Você deve aceitar os termos'),
}).refine(
(data) => {
// Se o plano é pago, cartão é obrigatório
if (['profissional', 'empresarial'].includes(data.plano)) {
return !!data.cartao?.numero;
}
return true;
},
{
message: 'Cartão de crédito é obrigatório para planos pagos',
path: ['cartao'],
}
);
type CadastroForm = z.infer<typeof cadastroSchema>;
const FormularioCadastro = () => {
const [enviado, setEnviado] = React.useState(false);
const {
register,
control,
watch,
handleSubmit,
formState: { errors },
} = useForm<CadastroForm>({
resolver: zodResolver(cadastroSchema),
defaultValues: {
interesses: [],
plano: 'gratuito',
},
});
const planoSelecionado = watch('plano');
const onSubmit = async (data: CadastroForm) => {
setEnviado(true);
console.log('Dados validados:', data);
// Enviar para servidor
};
return (
<form onSubmit={handleSubmit(onSubmit)} style={{ maxWidth: '600px', margin: '0 auto' }}>
<h2>Cadastro</h2>
{/ Nome /}
<div style={{ marginBottom: '1.5rem' }}>
<label style={{ display: 'block', marginBottom: '0.5rem' }}>Nome</label>
<input
{...register('nome')}
style={{
width: '100%',
padding: '0.5rem',
border: 2px solid ${errors.nome ? '#dc2626' : '#e5e7eb'},
borderRadius: '4px',
}}
/>
{errors.nome && <p style={{ color: '#dc2626', marginTop: '0.25rem' }}>{errors.nome.message}</p>}
</div>
{/ Email /}
<div style={{ marginBottom: '1.5rem' }}>
<label style={{ display: 'block', marginBottom: '0.5rem' }}>Email</label>
<input
{...register('email')}
type="email"
style={{
width: '100%',
padding: '0.5rem',
border: 2px solid ${errors.email ? '#dc2626' : '#e5e7eb'},
borderRadius: '4px',
}}
/>
{errors.email && <p style={{ color: '#dc2626', marginTop: '0.25rem' }}>{errors.email.message}</p>}
</div>
{/ Data de Nascimento /}
<div style={{ marginBottom: '1.5rem' }}>
<label style={{ display: 'block', marginBottom: '0.5rem' }}>Data de Nascimento</label>
<input
{...register('dataNascimento')}
type="date"
style={{
width: '100%',
padding: '0.5rem',
border: 2px solid ${errors.dataNascimento ? '#dc2626' : '#e5e7eb'},
borderRadius: '4px',
}}
/>
{errors.dataNascimento && <p style={{ color: '#dc2626', marginTop: '0.25rem' }}>{errors.dataNascimento.message}</p>}
</div>
{/ Plano /}
<div style={{ marginBottom: '1.5rem' }}>
<label style={{ display: 'block', marginBottom: '0.5rem' }}>Plano</label>
<select
{...register('plano')}
style={{
width: '100%',
padding: '0.5rem',
border: '2px solid #e5e7eb',
borderRadius: '4px',
}}
>
<option value="gratuito">Gratuito</option>
<option value="profissional">Profissional</option>
<option value="empresarial">Empresarial</option>
</select>
</div>
{/ Cartão (condicional) /}
{['profissional', 'empresarial'].includes(planoSelecionado) && (
<div style={{ marginBottom: '1.5rem', padding: '1rem', background: '#f3f4f6', borderRadius: '4px' }}>
<h4>Informações do Cartão</h4>
<div style={{ marginBottom: '1rem' }}>
<label style={{ display: 'block', marginBottom: '0.5rem' }}>Número</label>
<input
{...register('cartao.numero')}
placeholder="1234567890123456"
style={{
width: '100%',
padding: '0.5rem',
border: 2px solid ${errors.cartao?.numero ? '#dc2626' : '#e5e7eb'},
borderRadius: '4px',
}}
/>
{errors.cartao?.numero && <p style={{ color: '#dc2626' }}>{errors.cartao.numero.message}</p>}
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '0.5rem' }}>
<div>
<label style={{ display: 'block', marginBottom: '0.5rem' }}>Mês</label>
<input {...register('cartao.mes')} placeholder="MM" style={{ width: '100%', padding: '0.5rem', border: '2px solid #e5e7eb', borderRadius: '4px' }} />
</div>
<div>
<label style={{ display: 'block', marginBottom: '0.5rem' }}>Ano</label>
<input {...register('cartao.ano')} placeholder="YYYY" style={{ width: '100%', padding: '0.5rem', border: '2px solid #e5e7eb', borderRadius: '4px' }} />
</div>
<div>
<label style={{ display: 'block', marginBottom: '0.5rem' }}>CVV</label>
<input {...register('cartao.cvv')} placeholder="123" style={{ width: '100%', padding: '0.5rem', border: '2px solid #e5e7eb', borderRadius: '4px' }} />
</div>
</div>
</div>
)}
{/ Interesses /}
<div style={{ marginBottom: '1.5rem' }}>
<label style={{ display: 'block', marginBottom: '0.5rem' }}>Interesses</label>
<div>
{['Tecnologia', 'Negócios', 'Design', 'Marketing'].map((interesse) => (
<label key={interesse} style={{ display: 'flex', alignItems: 'center', marginBottom: '0.5rem' }}>
<input
type="checkbox"
value={interesse}
{...register('interesses')}
style={{ marginRight: '0.5rem' }}
/>
{interesse}
</label>
))}
</div>
{errors.interesses && <p style={{ color: '#dc2626' }}>{errors.interesses.message}</p>}
</div>
{/ Termos /}
<div style={{ marginBottom: '1.5rem' }}>
<label style={{ display: 'flex', alignItems: 'center' }}>
<input
type="checkbox"
{...register('termos')}
style={{ marginRight: '0.5rem' }}
/>
Aceito os termos e condições
</label>
{errors.termos && <p style={{ color: '#dc2626' }}>{errors.termos.message}</p>}
</div>
<button
type="submit"
style={{
width: '100%',
padding: '0.75rem',
background: '#3b82f6',
color: 'white',
border: 'none',
borderRadius: '4px',
fontWeight: 'bold',
cursor: 'pointer',
}}
>
Cadastrar
</button>
{enviado && <p style={{ marginTop: '1rem', color: '#16a34a' }}>Cadastro realizado com sucesso!</p>}
</form>
);
};
export default FormularioCadastro;</code></pre>
<h2>Conclusão</h2>
<p>Ao dominar Zod com React Hook Form, você alcança três ganhos práticos na sua carreira de desenvolvedor:</p>
<ol>
<li><strong>Tipagem Segura e Validação Unificada</strong>: Ao usar <code>z.infer<typeof schema></code>, você elimina a duplicação entre tipos TypeScript e validações runtime. Seu código fica mais mantível porque a "fonte da verdade" é única — o schema Zod.</li>
</ol>
<ol>
<li><strong>Controle Total Sobre Mensagens de Erro</strong>: Através de <code>.refine()</code>, <code>.superRefine()</code> e schemas condicionais, você pode criar experiências de erro contextualizadas. Usuários entendem exatamente o que fazer, reduzindo fricção no preenchimento de formulários.</li>
</ol>
<ol>
<li><strong>Escalabilidade em Formulários Complexos</strong>: Estruturas aninhadas, campos dinâmicos e validações cruzadas não são mais desafiadoras. A combinação Zod + Hook Form oferece abstrações que crescem com a complexidade da sua aplicação sem virar "spaghetti code".</li>
</ol>
<h2>Referências</h2>
<ul>
<li><a href="https://zod.dev/" target="_blank" rel="noopener noreferrer">Documentação Oficial do Zod</a></li>
<li><a href="https://react-hook-form.com/form-builder" target="_blank" rel="noopener noreferrer">React Hook Form com Zod Integration</a></li>
<li><a href="https://www.typescriptlang.org/docs/handbook/type-inference.html" target="_blank" rel="noopener noreferrer">TypeScript Handbook: Type Inference</a></li>
<li><a href="https://blog.logrocket.com/build-strongly-typed-forms-react-zod/" target="_blank" rel="noopener noreferrer">Build Forms with Zod - LogRocket</a></li>
<li><a href="https://www.smashingmagazine.com/2022/09/inline-validation-web-forms-html5-javascript/" target="_blank" rel="noopener noreferrer">Advanced React Hook Form Patterns</a></li>
</ul>
<p><!-- FIM --></p>