Validação de formulários com Claude Code, React Hook Form e Zod
Implemente validação com Claude Code, React Hook Form, Zod, servidor, erros de API, i18n, acessibilidade e testes.
Comece pelo contrato de dados
Validação de formulário não é apenas mostrar uma mensagem quando um campo está vazio. Em produtos reais, ela aparece em trial de SaaS, contato comercial, reserva, checkout, onboarding e telas administrativas. Um formulário pode parecer pronto e ainda assim permitir envio duplicado, enviar número como texto, esconder erros do servidor ou ser difícil para quem navega por teclado e leitor de tela.
Claude Code ajuda porque validação é uma tarefa com estrutura clara. React Hook Form controla estado e submit. Zod define o schema, ou seja, o contrato dos dados. O resolver conecta os dois. Validação no servidor significa checar o JSON novamente na API, porque o navegador pode ser contornado. Normalização de erros é transformar falhas de Zod, regras de negócio e JSON inválido em um formato único. i18n é internacionalização, e acessibilidade é garantir que o erro seja percebido por todos.
Neste guia, usamos um formulário de contato B2B para implementar React Hook Form, Zod, validação server-side, normalização de erros de API, acessibilidade, i18n e testes. Leia também React Hook Form com Claude Code e validação Zod com Claude Code. Confira a documentação oficial: Claude Code overview, React Hook Form useForm, React Hook Form Resolvers, Zod, Next.js Route Handlers e Testing Library.
flowchart TD
A["User input"] --> B["React Hook Form"]
B --> C["zodResolver"]
C --> D{"Client valid?"}
D -->|No| E["Accessible field errors"]
D -->|Yes| F["POST /api/contact"]
F --> G["Server Zod validation"]
G --> H["Normalize API errors"]
H --> I["setError or root message"]
G --> J["Persist or notify"]
Casos de uso mudam a validação
Não peça apenas “adicione validação”. Diga qual é o formulário.
| Caso | Validação importante | Armadilha comum |
|---|---|---|
| Trial de SaaS | E-mail corporativo, número de pessoas, plano, aceite | Não decidir se e-mail pessoal é permitido |
| Contato | Nome, e-mail, categoria, tamanho da mensagem, URLs suspeitas | Validar só no cliente e confiar na API |
| Edição admin | Papel, permissão, ID imutável, campos permitidos | Esconder campo na UI, mas aceitar no servidor |
| Reserva ou pré-pagamento | Data, quantidade, telefone, endereço, estoque | Criar duas reservas por duplo clique |
Use um prompt assim:
Implement a contact form.
Only change the files in this feature.
Use React Hook Form, Zod, and @hookform/resolvers/zod.
Validate on the client and again in the API using the same schema.
Normalize API failures as { ok: false, errors: [{ path, message }] }.
Include duplicate-submit prevention, aria-invalid, aria-describedby, role="alert",
i18n message keys, and Vitest/Testing Library tests.
Use copy-pasteable TypeScript and React, not pseudocode.
Esse texto obriga Claude Code a lidar com o caminho ruim: erro, servidor, reenvio, acessibilidade e teste.
Schema Zod para copiar
O schema usa chaves de mensagem. A API devolve a chave, e a UI traduz para o idioma do usuário.
// src/features/contact/contactSchema.ts
import { z } from "zod";
export const contactSchema = z
.object({
name: z.string().trim().min(1, "validation.name.required").max(60, "validation.name.tooLong"),
email: z.string().trim().min(1, "validation.email.required").email("validation.email.invalid"),
plan: z.enum(["starter", "team", "enterprise"], { message: "validation.plan.invalid" }),
seats: z
.number({ message: "validation.seats.number" })
.int("validation.seats.integer")
.min(1, "validation.seats.min")
.max(200, "validation.seats.max"),
message: z.string().trim().min(20, "validation.message.tooShort").max(1000, "validation.message.tooLong"),
locale: z.enum(["ja", "en"], { message: "validation.locale.invalid" }),
agreeToTerms: z.boolean().refine((value) => value === true, "validation.terms.required"),
})
.strict();
export type ContactFormData = z.infer<typeof contactSchema>;
export const defaultContactValues: ContactFormData = {
name: "",
email: "",
plan: "starter",
seats: 1,
message: "",
locale: "en",
agreeToTerms: false,
};
seats é number de propósito. Um input number do HTML ainda pode virar string se for registrado errado. Use valueAsNumber no React Hook Form ou um schema de API com z.coerce.number() para dados brutos.
Servidor e erros de API normalizados
Validação no cliente melhora a experiência, mas não protege o sistema. Na API, trate o body como unknown até passar pelo Zod.
// src/app/api/contact/route.ts
import { z } from "zod";
import { contactSchema, type ContactFormData } from "@/features/contact/contactSchema";
type FieldPath = keyof ContactFormData | "root";
export type ApiFieldError = {
path: FieldPath;
message: string;
};
export type ContactApiResponse =
| { ok: true; id: string }
| { ok: false; errors: ApiFieldError[] };
function normalizeZodError(error: z.ZodError): ApiFieldError[] {
return error.issues.map((issue) => {
const firstPath = issue.path[0];
return {
path: typeof firstPath === "string" ? (firstPath as FieldPath) : "root",
message: issue.message,
};
});
}
function jsonResponse(body: ContactApiResponse, status: number): Response {
return Response.json(body, { status });
}
async function isBlockedDomain(email: string): Promise<boolean> {
return email.toLowerCase().endsWith("@example.invalid");
}
export async function POST(request: Request): Promise<Response> {
let body: unknown;
try {
body = await request.json();
} catch {
return jsonResponse({ ok: false, errors: [{ path: "root", message: "validation.json.invalid" }] }, 400);
}
const parsed = contactSchema.safeParse(body);
if (!parsed.success) {
return jsonResponse({ ok: false, errors: normalizeZodError(parsed.error) }, 422);
}
if (await isBlockedDomain(parsed.data.email)) {
return jsonResponse({ ok: false, errors: [{ path: "email", message: "validation.email.blocked" }] }, 409);
}
// Replace this with database insert, CRM sync, or email notification.
const id = crypto.randomUUID();
return jsonResponse({ ok: true, id }, 201);
}
Com isso, a UI só precisa ler path e message, sem conhecer a origem do erro.
Componente acessível com React Hook Form
O componente previne duplo envio, usa atributos ARIA e mapeia erro de API para o campo correto.
// src/features/contact/ContactForm.tsx
"use client";
import { useState } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { contactSchema, defaultContactValues, type ContactFormData } from "./contactSchema";
import type { ApiFieldError, ContactApiResponse } from "@/app/api/contact/route";
const messages = {
en: {
"validation.name.required": "Enter your name.",
"validation.name.tooLong": "Name must be 60 characters or fewer.",
"validation.email.required": "Enter your email address.",
"validation.email.invalid": "Enter a valid email address.",
"validation.email.blocked": "This email domain is not allowed.",
"validation.plan.invalid": "Choose a plan.",
"validation.seats.number": "Seats must be a number.",
"validation.seats.integer": "Seats must be an integer.",
"validation.seats.min": "Seats must be at least 1.",
"validation.seats.max": "Seats must be 200 or fewer.",
"validation.message.tooShort": "Message must be at least 20 characters.",
"validation.message.tooLong": "Message must be 1000 characters or fewer.",
"validation.locale.invalid": "Locale is invalid.",
"validation.terms.required": "You must agree to the terms.",
"validation.json.invalid": "The submitted body could not be read.",
"form.submitError": "Submit failed. Please try again later.",
},
} as const;
type Locale = keyof typeof messages;
const formFields = ["name", "email", "plan", "seats", "message", "locale", "agreeToTerms"] as const;
type FormField = (typeof formFields)[number];
function t(locale: Locale, key: string): string {
const table = messages[locale] as Record<string, string>;
return table[key] ?? key;
}
function isFormField(path: ApiFieldError["path"]): path is FormField {
return formFields.includes(path as FormField);
}
export function ContactForm({ locale = "en" }: { locale?: Locale }) {
const [serverMessage, setServerMessage] = useState<string | null>(null);
const { register, handleSubmit, setError, reset, formState: { errors, isSubmitting } } =
useForm<ContactFormData>({ resolver: zodResolver(contactSchema), defaultValues: { ...defaultContactValues, locale }, mode: "onBlur" });
async function onValidSubmit(values: ContactFormData) {
setServerMessage(null);
const response = await fetch("/api/contact", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(values) });
const result = (await response.json()) as ContactApiResponse;
if (!response.ok || !result.ok) {
const apiErrors = result.ok ? [] : result.errors;
for (const error of apiErrors) {
if (isFormField(error.path)) setError(error.path, { type: "server", message: t(locale, error.message) });
else setServerMessage(t(locale, error.message));
}
if (apiErrors.length === 0) setServerMessage(t(locale, "form.submitError"));
return;
}
reset({ ...defaultContactValues, locale });
}
return (
<form onSubmit={handleSubmit(onValidSubmit)} noValidate>
{serverMessage ? <p role="alert" aria-live="assertive">{serverMessage}</p> : null}
<label htmlFor="contact-name">Name</label>
<input id="contact-name" aria-invalid={Boolean(errors.name)} aria-describedby={errors.name ? "contact-name-error" : undefined} {...register("name")} />
{errors.name?.message ? <p id="contact-name-error" role="alert">{t(locale, errors.name.message)}</p> : null}
<label htmlFor="contact-email">Email address</label>
<input id="contact-email" type="email" aria-invalid={Boolean(errors.email)} aria-describedby={errors.email ? "contact-email-error" : undefined} {...register("email")} />
{errors.email?.message ? <p id="contact-email-error" role="alert">{t(locale, errors.email.message)}</p> : null}
<label htmlFor="contact-plan">Plan</label>
<select id="contact-plan" {...register("plan")}><option value="starter">Starter</option><option value="team">Team</option><option value="enterprise">Enterprise</option></select>
<label htmlFor="contact-seats">Seats</label>
<input id="contact-seats" type="number" min={1} max={200} {...register("seats", { valueAsNumber: true })} />
<label htmlFor="contact-message">Message</label>
<textarea id="contact-message" rows={6} {...register("message")} />
<label><input type="checkbox" {...register("agreeToTerms")} />I agree to the terms</label>
{errors.agreeToTerms?.message ? <p role="alert">{t(locale, errors.agreeToTerms.message)}</p> : null}
<button type="submit" disabled={isSubmitting} aria-busy={isSubmitting}>{isSubmitting ? "Submitting..." : "Submit"}</button>
</form>
);
}
Em um produto em português, traduza também labels e botões. O importante é manter a estrutura de erro.
Testes, armadilhas e resultado
// src/features/contact/ContactForm.test.tsx
import "@testing-library/jest-dom/vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { ContactForm } from "./ContactForm";
import { contactSchema } from "./contactSchema";
const validInput = {
name: "Masa",
email: "masa@example.com",
plan: "team",
seats: 3,
message: "I want to improve validation in a Claude Code workflow.",
locale: "en",
agreeToTerms: true,
} as const;
it("accepts valid schema input", () => {
expect(contactSchema.safeParse(validInput).success).toBe(true);
});
it("rejects string seats", () => {
expect(contactSchema.safeParse({ ...validInput, seats: "3" }).success).toBe(false);
});
it("does not submit invalid form", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch");
render(<ContactForm locale="en" />);
await userEvent.click(screen.getByRole("button", { name: "Submit" }));
expect(await screen.findByText("Enter your name.")).toBeInTheDocument();
expect(fetchMock).not.toHaveBeenCalled();
});
Os erros mais comuns são validar só no cliente, esquecer duplo envio, deixar números virarem strings, perder erros do servidor, tratar acessibilidade apenas como cor vermelha e deixar Claude Code alterar arquivos fora do escopo.
Formulários fazem parte da conversão. Se o trial quebra, tráfego de SEO e anúncios é desperdiçado. Se o contato não explica o erro, o lead some. Em treinamento e consultoria Claude Code, ClaudeCodeLab ajuda a ajustar prompts, schemas, testes e checklist de review em um repositório real.
No teste de Masa, a primeira versão parecia boa, mas seats chegava como string e o erro de domínio bloqueado não aparecia no campo email. Com valueAsNumber, erros de API normalizados e testes com Testing Library, os dois problemas ficaram reproduzíveis e fáceis de corrigir. Message keys também facilitaram verificar UI em japonês e inglês com a mesma resposta do servidor.
Resumo
Com Claude Code, trate validação como uma fronteira completa: React Hook Form, Zod, servidor, erros de API, acessibilidade, i18n e testes. O objetivo não é apenas gerar rápido, mas manter o formulário confiável quando o usuário erra ou quando o servidor rejeita os dados.
PDF grátis: cheatsheet do Claude Code
Informe seu e-mail e baixe uma página com comandos, hábitos de revisão e workflows seguros.
Cuidamos dos seus dados e não enviamos spam.
Sobre o autor
Masa
Engenheiro focado em workflows práticos com Claude Code.
Artigos relacionados
Workflow Obsidian para CLAUDE.md com Claude Code
Transforme notas de trabalho do Obsidian em notas operacionais CLAUDE.md para preservar contexto.
Claude Code Revenue CTA Routing: artigos para PDF, Gumroad e consultoria
Um fluxo com Claude Code para levar leitores ao PDF grátis, Gumroad ou consultoria conforme intenção.
Regras de handoff para equipes com Claude Code: evidências, permissões, rollback e receita
Formato prático para entregar trabalho do Claude Code com prova, permissões, rollback, PDF grátis, Gumroad e consultoria.