React Hook Form com Claude Code: implementação segura
Crie formulários com useForm, Zod, erros acessíveis, estado de envio, testes e prompts seguros para Claude Code.
Defina o contrato do formulário antes do código
React Hook Form é uma biblioteca leve para criar formulários em React. Em vez de guardar cada tecla em useState, ela aproveita o comportamento nativo do navegador e oferece APIs como register, handleSubmit e formState. Para quem está começando, isso ajuda a responder três perguntas: onde os valores são coletados, quando a validação roda e como impedir um segundo envio enquanto a requisição ainda está em andamento.
Claude Code consegue criar o componente, o schema Zod, a rota de API, os testes e um primeiro refactor na mesma sessão. Isso é útil, mas formulários costumam ficar no caminho de receita: contato comercial, teste de produto, pesquisa antes da compra, newsletter ou edição de perfil. Se o pedido for apenas “crie um formulário”, você pode receber uma tela bonita sem erro acessível, validação no servidor ou estado de envio confiável.
Este guia usa um formulário de contato para explicar useForm, zodResolver, erros por campo, estado de envio, revalidação na API, testabilidade e prompts seguros para Claude Code. Para padrões gerais de React, leia desenvolvimento React com Claude Code. Para schemas, veja também validação Zod com Claude Code.
Arquitetura: schema no centro
React Hook Form controla o fluxo do formulário. Zod descreve quais dados são válidos. zodResolver, do pacote @hookform/resolvers/zod, conecta os dois para que React Hook Form execute o schema durante a validação.
flowchart TD
A["Usuário preenche campos"] --> B["React Hook Form register"]
B --> C["zodResolver valida schema"]
C --> D{"Entrada válida"}
D -->|Não| E["Mostrar erros de campo"]
D -->|Sim| F["handleSubmit envia valores"]
F --> G["API valida o mesmo schema"]
G --> H["Salvar, notificar ou sincronizar CRM"]
Em termos simples, useForm é o controlador, o schema Zod é a lista de regras e o resolver é o adaptador. Ao pedir mudanças ao Claude Code, nomear essas partes reduz alterações desnecessárias. Depois você pode pedir: “atualize schema, select, API e testes juntos”.
Para conferir detalhes atuais, use fontes oficiais: useForm do React Hook Form, React Hook Form Resolvers, API do Zod, referência do React para <input>, e documentação do Claude Code: overview e commands.
Schema Zod pronto para copiar
Comece separando as regras de validação em um arquivo próprio. O exemplo abaixo inclui nome, email, categoria, mensagem e consentimento de contato. z.infer cria o tipo TypeScript a partir do schema, evitando uma interface duplicada que possa ficar diferente da validação real.
// src/features/inquiry/inquirySchema.ts
import { z } from "zod";
export const inquirySchema = z.object({
name: z
.string()
.trim()
.min(1, "Informe seu nome")
.max(80, "O nome deve ter no máximo 80 caracteres"),
email: z
.string()
.trim()
.email("Informe um email válido"),
category: z.enum(["consulting", "support", "billing"], {
error: "Escolha uma categoria",
}),
message: z
.string()
.trim()
.min(10, "Escreva pelo menos 10 caracteres")
.max(1000, "A mensagem deve ter no máximo 1000 caracteres"),
agreeToContact: z.boolean().refine((value) => value, {
message: "O consentimento de contato é obrigatório",
}),
});
export type InquiryFormValues = z.infer<typeof inquirySchema>;
A categoria é um enum porque o valor enviado precisa ser previsível. Em um produto real, esse valor pode encaminhar o lead para vendas, suporte, cobrança ou CRM. No prompt para Claude Code, escreva tanto o rótulo visível quanto o valor enviado: “rótulo: Suporte técnico, valor: support”. Rótulos podem ser traduzidos; valores devem ser estáveis.
useForm para valores, erros e envio
O componente abaixo conecta o schema ao React Hook Form. mode: "onBlur" valida quando a pessoa sai do campo. Para formulários de contato, isso costuma ser menos agressivo do que exibir erro a cada tecla. No envio final, handleSubmit valida novamente antes de chamar onSubmit.
// src/features/inquiry/InquiryForm.tsx
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { inquirySchema, type InquiryFormValues } from "./inquirySchema";
async function sendInquiry(values: InquiryFormValues) {
const response = await fetch("/api/inquiry", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(values),
});
if (!response.ok) {
throw new Error("Failed to send inquiry");
}
}
export function InquiryForm() {
const {
register,
handleSubmit,
reset,
setError,
formState: { errors, isSubmitting },
} = useForm<InquiryFormValues>({
resolver: zodResolver(inquirySchema),
mode: "onBlur",
defaultValues: {
name: "",
email: "",
message: "",
agreeToContact: false,
},
});
const onSubmit = async (values: InquiryFormValues) => {
try {
await sendInquiry(values);
reset();
} catch {
setError("root", {
type: "server",
message: "Não foi possível enviar o formulário. Tente novamente em instantes.",
});
}
};
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<div>
<label htmlFor="name">Nome</label>
<input
id="name"
autoComplete="name"
aria-invalid={errors.name ? "true" : "false"}
aria-describedby={errors.name ? "name-error" : undefined}
{...register("name")}
/>
{errors.name && (
<p id="name-error" role="alert">
{errors.name.message}
</p>
)}
</div>
<div>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
autoComplete="email"
aria-invalid={errors.email ? "true" : "false"}
aria-describedby={errors.email ? "email-error" : undefined}
{...register("email")}
/>
{errors.email && (
<p id="email-error" role="alert">
{errors.email.message}
</p>
)}
</div>
<div>
<label htmlFor="category">Assunto</label>
<select
id="category"
aria-invalid={errors.category ? "true" : "false"}
aria-describedby={errors.category ? "category-error" : undefined}
{...register("category")}
>
<option value="">Escolha uma opção</option>
<option value="consulting">Consultoria de implementação</option>
<option value="support">Suporte técnico</option>
<option value="billing">Cobrança ou contrato</option>
</select>
{errors.category && (
<p id="category-error" role="alert">
{errors.category.message}
</p>
)}
</div>
<div>
<label htmlFor="message">Mensagem</label>
<textarea
id="message"
rows={6}
aria-invalid={errors.message ? "true" : "false"}
aria-describedby={errors.message ? "message-error" : undefined}
{...register("message")}
/>
{errors.message && (
<p id="message-error" role="alert">
{errors.message.message}
</p>
)}
</div>
<label>
<input type="checkbox" {...register("agreeToContact")} />
Aceito ser contatado sobre esta solicitação
</label>
{errors.agreeToContact && (
<p role="alert">{errors.agreeToContact.message}</p>
)}
{errors.root && <p role="alert">{errors.root.message}</p>}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Enviando..." : "Enviar solicitação"}
</button>
</form>
);
}
O ponto importante é a relação entre campo e erro. Cada campo usa aria-invalid, cada mensagem usa role="alert" e aria-describedby conecta a entrada à explicação. Isso melhora acessibilidade e também deixa os testes mais claros. Veja também acessibilidade com Claude Code.
Revalidar na API
Validação no cliente melhora a experiência, mas não protege o servidor. Uma requisição pode ser enviada diretamente para a API. Por isso, reutilize o mesmo schema antes de salvar, enviar email ou sincronizar o CRM.
// app/api/inquiry/route.ts
import { NextResponse } from "next/server";
import { inquirySchema } from "@/features/inquiry/inquirySchema";
export async function POST(request: Request) {
const payload = await request.json().catch(() => null);
const parsed = inquirySchema.safeParse(payload);
if (!parsed.success) {
return NextResponse.json(
{
error: "Invalid inquiry",
fields: parsed.error.flatten().fieldErrors,
},
{ status: 400 },
);
}
// TODO: Salvar no banco, enviar email ou sincronizar CRM.
return NextResponse.json({ ok: true });
}
Ao pedir essa rota ao Claude Code, seja direto: “reutilize inquirySchema, retorne 400 com erros por campo e deixe email ou CRM como TODO”. Segredos, retentativas e deduplicação devem ser tarefas separadas.
Testes para comportamento real
Formulários quebram de maneiras pouco visíveis. Teste pelo menos envio vazio, envio válido, falha no servidor e botão desativado. Com Vitest e React Testing Library, você pode confirmar erros e chamada a fetch.
// src/features/inquiry/InquiryForm.test.tsx
import { afterEach, expect, test, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { InquiryForm } from "./InquiryForm";
afterEach(() => {
vi.unstubAllGlobals();
});
test("mostra erros ao enviar vazio", async () => {
render(<InquiryForm />);
await userEvent.click(screen.getByRole("button", { name: "Enviar solicitação" }));
expect(await screen.findAllByRole("alert")).toHaveLength(5);
});
test("envia valores válidos para a API", async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response("{}", { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
render(<InquiryForm />);
await userEvent.type(screen.getByLabelText("Nome"), "Masa");
await userEvent.type(screen.getByLabelText("Email"), "masa@example.com");
await userEvent.selectOptions(screen.getByLabelText("Assunto"), "consulting");
await userEvent.type(
screen.getByLabelText("Mensagem"),
"Quero implementar React Hook Form com segurança.",
);
await userEvent.click(
screen.getByLabelText("Aceito ser contatado sobre esta solicitação"),
);
await userEvent.click(screen.getByRole("button", { name: "Enviar solicitação" }));
expect(fetchMock).toHaveBeenCalledWith(
"/api/inquiry",
expect.objectContaining({ method: "POST" }),
);
});
Você pode pedir ao Claude Code que escreva primeiro os testes que falham e depois implemente. Para fluxos completos no navegador, use testes Playwright com Claude Code. Para métricas, dispare o evento depois de a API aceitar o formulário, não apenas no clique; isso se conecta a analytics com Claude Code.
Prompt seguro para Claude Code
Um bom prompt inclui escopo, restrições, comandos de verificação e o que não deve ser feito.
Implemente um formulário de contato com React Hook Form e Zod.
Escopo:
- Alterar apenas src/features/inquiry e app/api/inquiry.
- Usar useForm, zodResolver e tipos TypeScript derivados do schema.
- Campos: name, email, category, message, agreeToContact.
- Mostrar erros com role="alert" e aria-describedby.
- Desativar o botão enquanto isSubmitting for true.
- Revalidar o mesmo schema Zod na rota de API.
- Adicionar testes com Vitest + Testing Library.
Verificação:
- npm test -- InquiryForm
- npm run typecheck
Não fazer:
- Não adicionar nova biblioteca de UI.
- Não renomear valores existentes de category.
- Não implementar email real, CRM ou segredos nesta tarefa.
Para mudanças pequenas, detalhe o contrato. Em vez de “adicione uma categoria”, escreva “adicione o rótulo Pedido de treinamento com valor training, atualizando enum, select, API, testes e mapeamento de analytics”. Claude Code pode encontrar arquivos, mas o contrato precisa vir de você.
Casos de uso
| Caso | Boa estrutura | Atenção |
|---|---|---|
| Formulário de contato | Zod + React Hook Form + revalidação API | Medir leads concluídos, não cliques |
| Edição de perfil | Carregar dados em defaultValues | Após salvar, chamar reset(savedValues) |
| Pesquisa antes da compra | Combinar select, radio e checkbox | Alinhar valores com produto ou CRM |
| Busca administrativa | Validação leve e URL query | Não chamar API a cada tecla |
A regra comum é separar rótulo visual e valor enviado. Rótulos podem ser traduzidos e reescritos. Valores enviados precisam ser estáveis porque relatórios, automações e backend dependem deles.
Erros comuns
O primeiro é validar apenas no navegador. Importe o schema compartilhado na API e chame safeParse antes de processar o payload.
O segundo é perder isSubmitting cedo demais. Se onSubmit não usa await no trabalho assíncrono, o botão pode reativar antes da hora.
O terceiro é deixar erros longe do campo. Um aviso no topo pode ajudar, mas cada campo precisa de sua mensagem próxima.
O quarto é deixar Claude Code criar um novo design system. Se já existem TextField, Select, Button ou toast, peça explicitamente para reutilizar.
O quinto é esquecer o pós-envio: mensagem de sucesso, página de agradecimento, email, evento de analytics e CRM precisam ser planejados.
CTA de monetização
A qualidade do formulário deve ser medida pelo funil que ele apoia: cadastro de PDF, lead de produto, compra de template ou pedido de consultoria. Defina o evento de negócio antes de pedir refactor ao Claude Code.
Para estudar sozinho, veja os materiais na página de produtos. Para aplicar Claude Code em formulários de equipe, comece por treinamento e consultoria. Um formulário é pequeno, mas costuma ser a porta entre conteúdo útil e receita.
Resultado testado
Masa testou essa estrutura em um fluxo pequeno de contato. O maior ganho foi centralizar o schema, porque isso evitou adicionar uma opção no select e esquecer o valor permitido na API. Os testes de envio vazio e envio válido também ajudaram. Depois de pedir mudanças ao Claude Code, eles encontraram rapidamente mensagens de erro ausentes e uma chamada fetch quebrada. Na prática, tratar o formulário como contrato de entrada é mais sustentável do que tratá-lo apenas como UI.
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.