//
Busque em todas as páginas da documentação
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
Estas receitas de habilidade são projetadas para Claude Code, mas também funcionam com outros agentes de codificação de IA que suportam arquivos de habilidade/instrução.
O conteúdo completo do SKILL.md que você pode copiar para .claude/skills/forms-validation/SKILL.md:
---
name: forms-validation-expert
description: "Manipulação avançada de formulários com React Hook Form, Zod e Server Actions. Use quando solicitado para: ajuda com formulários, validação, esquema Zod, padrão de formulário, formulário multi-etapas, formulário de upload de arquivo, react-hook-form, formulário de server action, erros de formulário."
allowed-tools: "Read, Write, Edit, Glob, Grep, Bash(npm:*), Bash(npx:*), Agent"
---
# Especialista em Formulários e Validação
Você é um especialista em formulários e validação para React e Next.js. Você constrói formulários que são type-safe, acessíveis, com progressive enhancement e fáceis de usar.
## Árvore de Decisão de Estratégia de Validação
1. **Este é um formulário Server Action (sem necessidade de JS no cliente)?**
- Sim -> Validação Zod no Server Action + useActionState
- Não -> Continuar
2. **O formulário tem interações complexas no lado do cliente?**
- Sim (campos condicionais, multi-etapas, arrays dinâmicos) -> React Hook Form + Zod
- Não (formulário simples, poucos campos) -> Formulário Server Action ou validação HTML nativa
3. **Você está usando shadcn/ui?**
- Sim -> Use o componente Form do shadcn (que envolve o React Hook Form)
- Não -> Use o React Hook Form diretamente
## Padrões de Esquema Zod
### Esquema Básico
```tsx
import \{ z \} from "zod";
const UserSchema = z.object(\{
name: z.string().min(2, "O nome deve ter pelo menos 2 caracteres"),
email: z.string().email("Endereço de e-mail inválido"),
age: z.coerce.number().min(13, "Deve ter pelo menos 13 anos").max(120),
role: z.enum(["admin", "user", "moderator"]),
bio: z.string().max(500).optional(),
\});
type User = z.infer<typeof UserSchema>;const PaymentSchema = z.discriminatedUnion("method", [
z.object(\{
method: z.literal("card"),
cardNumber: z.string().regex(/^\d\{16\}$/, "Deve ter 16 dígitos"),
expiry: z.string().regex(/^\d\{2\}\/\d\{2\}$/, "Formato MM/AA"),
cvv: z.string().regex(/^\d\{3,4\}$/, "3 ou 4 dígitos"),
\}),
z.object(\{
method: z.literal("paypal"),
paypalEmail: z.string().email(),
\}),
z.object(\{
method: z.literal("bank"),
accountNumber: z.string().min(8),
routingNumber: z.string().length(9),
\}),
]);const SignupSchema = z.object(\{
username: z
.string()
.min(3)
.refine(async (val) => \{
const exists = await checkUsernameExists(val);
return !exists;
\}, "Nome de usuário já em uso"),
email: z.string().email(),
password: z.string().min(8),
confirmPassword: z.string(),
\}).refine((data) => data.password === data.confirmPassword, \{
message: "As senhas não coincidem",
path: ["confirmPassword"],
\});const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
const ACCEPTED_TYPES = ["image/jpeg", "image/png", "image/webp"];
const UploadSchema = z.object(\{
avatar: z
.instanceof(File)
.refine((f) => f.size <= MAX_FILE_SIZE, "O arquivo deve ter menos de 5MB")
.refine(
(f) => ACCEPTED_TYPES.includes(f.type),
"Apenas JPEG, PNG e WebP são aceitos"
),
\});"use client";
import \{ useForm \} from "react-hook-form";
import \{ zodResolver \} from "@hookform/resolvers/zod";
import \{ z \} from "zod";
const schema = z.object(\{
name: z.string().min(2),
email: z.string().email(),
\});
type FormData = z.infer<typeof schema>;
export function ContactForm() \{
const \{
register,
handleSubmit,
formState: \{ errors, isSubmitting \},
reset,
\} = useForm<FormData>(\{
resolver: zodResolver(schema),
defaultValues: \{ name: "", email: "" \},
\});
const onSubmit = async (data: FormData) => \{
await submitToApi(data);
reset();
\};
return (
<form onSubmit=\{handleSubmit(onSubmit)\} noValidate>
<div>
<label htmlFor="name">Nome</label>
<input id="name" \{...register("name")\} aria-invalid=\{!!errors.name\} />
\{errors.name && (
<p role="alert" className="text-sm text-red-500">
\{errors.name.message\}
</p>
)\}
</div>
<div>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
\{...register("email")\}
aria-invalid=\{!!errors.email\}
/>
\{errors.email && (
<p role="alert" className="text-sm text-red-500">
\{errors.email.message\}
</p>
)\}
</div>
<button type="submit" disabled=\{isSubmitting\}>
\{isSubmitting ? "Enviando..." : "Enviar"\}
</button>
</form>
);
\}// actions.ts
"use server";
import \{ z \} from "zod";
const ContactSchema = z.object(\{
name: z.string().min(2),
email: z.string().email(),
message: z.string().min(10).max(1000),
\});
type FormState = \{
errors?: Record<string, string[]>;
message?: string;
success: boolean;
\};
export async function submitContact(
prevState: FormState,
formData: FormData
): Promise<FormState> \{
const raw = Object.fromEntries(formData);
const parsed = ContactSchema.safeParse(raw);
if (!parsed.success) \{
return \{
errors: parsed.error.flatten().fieldErrors as Record<string, string[]>,
success: false,
\};
\}
try \{
await db.contact.create(\{ data: parsed.data \});
return \{ success: true, message: "Mensagem enviada!" \};
\} catch \{
return \{ success: false, message: "Falha ao enviar. Tente novamente." \};
\}
\}// ContactForm.tsx
"use client";
import \{ useActionState \} from "react";
import \{ useFormStatus \} from "react-dom";
import \{ submitContact \} from "./actions";
function SubmitButton() \{
const \{ pending \} = useFormStatus();
return (
<button type="submit" disabled=\{pending\}>
\{pending ? "Enviando..." : "Enviar Mensagem"\}
</button>
);
\}
export function ContactForm() \{
const [state, formAction] = useActionState(submitContact, \{
success: false,
\});
return (
<form action=\{formAction\}>
<div>
<label htmlFor="name">Nome</label>
<input id="name" name="name" required />
\{state.errors?.name && (
<p className="text-red-500">\{state.errors.name[0]\}</p>
)\}
</div>
<div>
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" required />
\{state.errors?.email && (
<p className="text-red-500">\{state.errors.email[0]\}</p>
)\}
</div>
<div>
<label htmlFor="message">Mensagem</label>
<textarea id="message" name="message" required />
\{state.errors?.message && (
<p className="text-red-500">\{state.errors.message[0]\}</p>
)\}
</div>
\{state.message && (
<p className=\{state.success ? "text-green-500" : "text-red-500"\}>
\{state.message\}
</p>
)\}
<SubmitButton />
</form>
);
\}"use client";
import \{ useForm \} from "react-hook-form";
import \{ zodResolver \} from "@hookform/resolvers/zod";
import \{ z \} from "zod";
import \{
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
\} from "@/components/ui/form";
import \{ Input \} from "@/components/ui/input";
import \{ Button \} from "@/components/ui/button";
const schema = z.object(\{
username: z.string().min(3).max(20),
email: z.string().email(),
\});
export function ProfileForm() \{
const form = useForm<z.infer<typeof schema>>(\{
resolver: zodResolver(schema),
defaultValues: \{ username: "", email: "" \},
\});
return (
<Form \{...form\}>
<form onSubmit=\{form.handleSubmit(onSubmit)\} className="space-y-4">
<FormField
control=\{form.control\}
name="username"
render=\{(\{ field \}) => (
<FormItem>
<FormLabel>Nome de usuário</FormLabel>
<FormControl>
<Input placeholder="johndoe" \{...field\} />
</FormControl>
<FormDescription>Seu nome público de exibição.</FormDescription>
<FormMessage />
</FormItem>
)\}
/>
<Button type="submit">Salvar</Button>
</form>
</Form>
);
\}"use client";
import \{ useState \} from "react";
import \{ useForm \} from "react-hook-form";
import \{ zodResolver \} from "@hookform/resolvers/zod";
import \{ z \} from "zod";
const Step1Schema = z.object(\{
name: z.string().min(2),
email: z.string().email(),
\});
const Step2Schema = z.object(\{
address: z.string().min(5),
city: z.string().min(2),
zip: z.string().regex(/^\d\{5\}$/),
\});
const Step3Schema = z.object(\{
cardNumber: z.string().regex(/^\d\{16\}$/),
\});
const FullSchema = Step1Schema.merge(Step2Schema).merge(Step3Schema);
type FullFormData = z.infer<typeof FullSchema>;
const schemas = [Step1Schema, Step2Schema, Step3Schema] as const;
export function MultiStepForm() \{
const [step, setStep] = useState(0);
const form = useForm<FullFormData>(\{
resolver: zodResolver(schemas[step]),
mode: "onTouched",
defaultValues: \{
name: "", email: "", address: "", city: "", zip: "", cardNumber: "",
\},
\});
const onNext = async () => \{
const valid = await form.trigger();
if (valid) setStep((s) => s + 1);
\};
const onBack = () => setStep((s) => s - 1);
const onSubmit = async (data: FullFormData) => \{
// Validação final com o esquema completo
const result = FullSchema.safeParse(data);
if (result.success) await submitOrder(result.data);
\};
return (
<form onSubmit=\{form.handleSubmit(onSubmit)\}>
\{step === 0 && <Step1Fields form=\{form\} />\}
\{step === 1 && <Step2Fields form=\{form\} />\}
\{step === 2 && <Step3Fields form=\{form\} />\}
<div className="flex gap-2">
\{step > 0 && <button type="button" onClick=\{onBack\}>Voltar</button>\}
\{step < 2 ? (
<button type="button" onClick=\{onNext\}>Próximo</button>
) : (
<button type="submit">Fazer Pedido</button>
)\}
</div>
</form>
);
\}mode: "onTouched" no React Hook Form
## Exemplo de Trabalho
### Exemplo 1: Usuário pergunta "Construa um formulário de inscrição com validação"
**Prompt do usuário:** "Preciso de um formulário de inscrição com nome, e-mail, senha e confirmação de senha com validação Zod."
**A resposta guiada pela habilidade produziria:**
- Um esquema Zod com refinamento de confirmação de senha
- Um componente React Hook Form com zodResolver
- Exibição de erro inline com atributos aria
- Tipos TypeScript inferidos do esquema
- Validação tanto no cliente quanto no servidor
### Exemplo 2: Usuário pergunta "Como lidar com uploads de arquivos em um formulário?"
**A resposta guiada pela habilidade incluiria:**
- Esquema Zod com validação de Arquivo (tamanho, tipo)
- React Hook Form com `Controller` para a entrada de arquivo
- Server Action que processa FormData com o arquivo
- Padrão de indicação de progresso
## Mergulho Profundo
### Como a Habilidade Funciona
Esta habilidade fornece:
1. **Árvore de decisão** - Escolhe o padrão de formulário certo para o cenário
2. **Padrões de esquema** - Receitas Zod para necessidades comuns de validação
3. **Padrões de integração** - React Hook Form + Zod, Server Actions, shadcn/ui
4. **Regras de tratamento de erros** - Melhores práticas de acessibilidade e UX
5. **Padrões avançados** - Multi-etapas, upload de arquivos, formulários otimistas
### Personalização
- Adicione os padrões da biblioteca de componentes de formulário do seu projeto
- Inclua regras de validação personalizadas específicas do seu domínio
- Especifique convenções de exibição de erros
- Adicione padrões de integração de API para seu backend
### Como Instalar
```bash
mkdir -p .claude/skills/forms-validation
# Cole o conteúdo da Receita em .claude/skills/forms-validation/SKILL.md
<form> pai mais próximo, não do formulário no mesmo componente.formData.get() sempre retorna strings.Controller do React Hook Form ou register com manipulação manual.| Abordagem | Quando Usar |
|---|---|
| Formik | Projetos legados que já usam Formik |
| Conform | Validação de formulário server-first (combina bem com Remix) |
| Validação HTML Nativa | Formulários simples com requisitos básicos |
| Final Form | Alternativa leve ao React Hook Form |
| Valibot | Alternativa de menor bundle ao Zod |
useActionState para gerenciamento de estadoDefina o esquema em um arquivo compartilhado e importe-o em ambos os locais:
// schemas/contact.ts
import { z } from "zod";
export const ContactSchema = z.object({
name: z.string().min(2),
email: z.string().email(),
});
export type Contact = z.infer<typeof ContactSchema>;zodResolversafeParseformData.get() sempre retorna strings, mesmo para entradas numéricasz.coerce.number() converte a string em um número antes que qualquer validação ocorrauseFormStatus lê o status pendente do elemento <form> pai mais próximo<form>, ainda não há formulário paiSubmitButton separado que vive dentro do formuláriotype FormState = {
errors?: Record<string, string[]>;
message?: string;
success: boolean;
};
export async function submitContact(
prevState: FormState,
formData: FormData
): Promise<FormState> {
// ...
}FormDatauseActionStatez.discriminatedUnion("method", [...]) alterna as regras de validação com base em um campo discriminadordefaultValues que correspondam à forma completa do seu esquema Zodconst UploadSchema = z.object({
avatar: z
.instanceof(File)
.refine((f) => f.size <= 5 * 1024 * 1024, "Máximo 5MB")
.refine(
(f) => ["image/jpeg", "image/png"].includes(f.type),
"Apenas JPEG e PNG aceitos"
),
});z.instanceof(File) como o tipo base.refine() para verificações de tamanho e tipo MIMEStep1Schema, Step2Schema, etc.)zodResolver no React Hook Formform.trigger() para validar a etapa atual antes de avançarFullSchema mescladoimport { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
const schema = z.object({ name: z.string().min(2) });
type FormData = z.infer<typeof schema>;
const form = useForm<FormData>({
resolver: zodResolver(schema),
});z.infer<typeof schema>useForm<FormData>z.coerce.number() força a coerção da entrada antes que qualquer validação ocorraz.string().transform(Number) valida como string primeiro, depois transformaz.coerce é mais simples e diretoaria-invalid e role="alert" para acessibilidademode: "onTouched")Revisado por Chris St. John·Última atualização: 10 de jul. de 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥