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.