React Hook Form Zod
Convenção de formulários com React Hook Form + zod — schema, tipo e mensagem de erro vivem juntos, componente é só estrutura. Invoque ao criar ou revisar qualquer form.
React Hook Form + zod
Schema, tipo e mensagem de erro vivem juntos no arquivo de schema — nunca inline no componente. O
.tsx só importa o schema e o tipo; não declara interface/type de dados de form nem faz
validação manual (if (!email.includes('@'))). Toda regra de validação — obrigatoriedade, formato,
mensagem exibida ao usuário — é responsabilidade do zod.
// LoginForm.schema.ts
import { z } from 'zod';
export const loginSchema = z.object({
email: z.string().email('Email inválido'),
password: z.string().min(8, 'Mínimo 8 caracteres'),
});
export type LoginFormData = z.infer<typeof loginSchema>;
// LoginForm.tsx — só estrutura: hook de form, JSX, submit. Zero tipo, zero regra de validação.
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { loginSchema, type LoginFormData } from './LoginForm.schema';
import { Input } from '@/components/Input/Input';
export function LoginForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<LoginFormData>({ resolver: zodResolver(loginSchema) });
const onSubmit = async (data: LoginFormData) => {
// data já validado pelo zod — sem if/else de validação manual
await authService.login(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Input label="Email" error={errors.email?.message} {...register('email')} />
<Input label="Senha" type="password" error={errors.password?.message} {...register('password')} />
<button type="submit" disabled={isSubmitting}>Entrar</button>
</form>
);
}
Regras inegociáveis
- Schema e tipo derivado ficam num arquivo
.schema.tsseparado do componente, nunca declarados dentro do.tsx— mesmo quando usados em um único form. Schema de um único form:<Form>.schema.tsna mesma pasta do componente (verfrontend/react→ folder-structure). Schema usado por 2+ forms:src/schemas/. - O componente lê
errors.<campo>.message— a mensagem em si mora no schema, não em string solta na JSX. - Erro cross-field (confirmação de senha, etc.) vai no schema via
.refine()/.superRefine(), apontando o erro pro campo certo compath— nunca comparado manualmente noonSubmitou no JSX:export const signupSchema = z .object({ password: z.string().min(8, 'Mínimo 8 caracteres'), confirmPassword: z.string(), }) .refine((data) => data.password === data.confirmPassword, { message: 'As senhas não coincidem', path: ['confirmPassword'], }); - Resposta de API dentro de um form (ex.: erro 422 do backend) é reaplicada com
setError, mas o shape esperado da resposta ainda é validado por um schema zod antes de virar estado — não se confia emunknownsolto. - Validação de servidor é sempre a fonte de verdade, com ou sem RHF no client: a API que recebe o submit valida de novo com seu próprio schema (zod, DRF serializer, etc. — ver o skill de backend do projeto) — um form que só valida no browser pode ser contornado por requisição direta (curl/Postman).
- Trate sempre os 3 estados do submit:
loading(desabilita o botão viaisSubmitting), sucesso (feedback pro usuário) eerror(feedback de falha) — um form que só trata o caminho feliz esconde erro de rede/validação do usuário.
Campo de formulário global (Input, Textarea, Select)
Todo campo de formulário global (Input, Textarea, Select, etc.) segue um contrato fixo:
labeleerrorsão sempre props — o componente nunca recebe label ou mensagem de erro viachildren, só via parâmetro. É isso que permite reuso: o form só passa dados, o componente decide como renderizar.<label>e<span role="alert">do erro já vêm embutidos no componente, condicionados aerrorestar presente — nenhum form escreve{errors.campo && <span>...}manualmente.erroré sempre amessagedo zod (errors.campo?.message) — o componente não sabe nem precisa saber que existe zod, só recebe a string pronta.forwardRefobrigatório — orefdoregister()do RHF precisa chegar ao elemento nativo.
// Input/Input.tsx
import { forwardRef, type InputHTMLAttributes } from 'react';
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label: string;
error?: string;
}
export const Input = forwardRef<HTMLInputElement, InputProps>(
({ label, error, id, name, ...props }, ref) => {
const inputId = id ?? name;
return (
<div>
<label htmlFor={inputId}>{label}</label>
<input id={inputId} name={name} ref={ref} {...props} />
{error && <span role="alert">{error}</span>}
</div>
);
},
);
Input.displayName = 'Input';
Textarea e Select seguem exatamente o mesmo contrato (label, error, forwardRef), só trocando
o elemento nativo interno. Se o projeto usa shadcn/ui, components/ui/input.tsx gerado já serve de
base estilizada — ver frontend/shadcn-ui.
Skills relacionadas
- Estrutura de componente:
frontend/react - Type safety:
frontend/typescript