Skill experto en formularios y validación - Una receta de skill para Claude Code sobre manejo avanzado de formularios con React Hook Form, Zod y Server Actions
Estas recetas de skills están diseñadas para Claude Code, pero también funcionan con otros agentes de codificación con IA que admiten archivos de skill/instrucciones.
Receta
El contenido completo de SKILL.md que puedes copiar en .claude/skills/forms-validation/SKILL.md:
---
name: forms-validation-expert
description: "Manejo avanzado de formularios con React Hook Form, Zod y Server Actions. Úsalo cuando te pidan: form help, validation, Zod schema, form pattern, multi-step form, file upload form, react-hook-form, server action form, form errors."
allowed-tools: "Read, Write, Edit, Glob, Grep, Bash(npm:*), Bash(npx:*), Agent"
---
# Skill experto en formularios y validación
Eres un experto en formularios y validación para React y Next.js. Construyes formularios type-safe, accesibles, con mejora progresiva y fáciles de usar.
## Árbol de decisión de estrategia de validación
1. **¿Es un formulario con Server Action (sin JS en el cliente)?**
- Sí -> Validación Zod en la Server Action + useActionState
- No -> Continúa
2. **¿El formulario tiene interacciones complejas en el cliente?**
- Sí (campos condicionales, multi-paso, arrays dinámicos) -> React Hook Form + Zod
- No (formulario simple, pocos campos) -> Formulario con Server Action o validación HTML nativa
3. **¿Estás usando shadcn/ui?**
- Sí -> Usa el componente Form de shadcn (envuelve React Hook Form)
- No -> Usa React Hook Form directamente
## Patrones de esquemas Zod
### Esquema básico
```tsx
import \{ z \} from "zod";
const UserSchema = z.object(\{
name: z.string().min(2, "El nombre debe tener al menos 2 caracteres"),
email: z.string().email("Dirección de correo electrónico no válida"),
age: z.coerce.number().min(13, "Debes tener al menos 13 años").max(120),
role: z.enum(["admin", "user", "moderator"]),
bio: z.string().max(500).optional(),
\});
type User = z.infer<typeof UserSchema>;Validación condicional
const PaymentSchema = z.discriminatedUnion("method", [
z.object(\{
method: z.literal("card"),
cardNumber: z.string().regex(/^\d\{16\}$/, "Debe tener 16 dígitos"),
expiry: z.string().regex(/^\d\{2\}\/\d\{2\}$/, "Formato MM/AA"),
cvv: z.string().regex(/^\d\{3,4\}$/, "3 o 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),
\}),
]);Validación asíncrona
const SignupSchema = z.object(\{
username: z
.string()
.min(3)
.refine(async (val) => \{
const exists = await checkUsernameExists(val);
return !exists;
\}, "El nombre de usuario ya está en uso"),
email: z.string().email(),
password: z.string().min(8),
confirmPassword: z.string(),
\}).refine((data) => data.password === data.confirmPassword, \{
message: "Las contraseñas no coinciden",
path: ["confirmPassword"],
\});Validación de subida de archivos
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, "El archivo debe pesar menos de 5 MB")
.refine(
(f) => ACCEPTED_TYPES.includes(f.type),
"Solo se aceptan JPEG, PNG y WebP"
),
\});Patrón React Hook Form + Zod
"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">Nombre</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">Correo electrónico</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>
);
\}Patrón de formulario con Server Action
// 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: "¡Mensaje enviado!" \};
\} catch \{
return \{ success: false, message: "No se pudo enviar. Inténtalo de nuevo." \};
\}
\}// 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 mensaje"\}
</button>
);
\}
export function ContactForm() \{
const [state, formAction] = useActionState(submitContact, \{
success: false,
\});
return (
<form action=\{formAction\}>
<div>
<label htmlFor="name">Nombre</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">Correo electrónico</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">Mensaje</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>
);
\}Patrón de formulario shadcn
"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>Nombre de usuario</FormLabel>
<FormControl>
<Input placeholder="johndoe" \{...field\} />
</FormControl>
<FormDescription>Tu nombre público de visualización.</FormDescription>
<FormMessage />
</FormItem>
)\}
/>
<Button type="submit">Guardar</Button>
</form>
</Form>
);
\}Patrón de formulario multi-paso
"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) => \{
// Validación final con el 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\}>Atrás</button>\}
\{step < 2 ? (
<button type="button" onClick=\{onNext\}>Siguiente</button>
) : (
<button type="submit">Realizar pedido</button>
)\}
</div>
</form>
);
\}Reglas de manejo de errores
- Muestra errores de campo inline - Directamente debajo del input, no en un toast
- Usa aria-invalid y role="alert" - Para accesibilidad
- Muestra errores del servidor a nivel de formulario - Errores de red, auth, encima del botón de envío
- Valida al perder el foco, revalida al cambiar - Usa
mode: "onTouched"en React Hook Form - Nunca limpies el formulario ante un error - Preserva la entrada del usuario
- Proporciona mensajes de error específicos - "El nombre debe tener al menos 2 caracteres", no "Entrada no válida"
Reglas de arquitectura de formularios
- Fuente única de verdad para la validación - Define el esquema Zod una vez e infiere los tipos a partir de él
- La validación del lado del servidor es obligatoria - La validación del cliente es una mejora de UX, no seguridad
- Mejora progresiva - Los formularios con Server Action funcionan sin JavaScript
- Inputs controlados por defecto - Usa el controller de React Hook Form para inputs complejos
- Haz debounce de la validación asíncrona - No golpees el servidor en cada pulsación de tecla
## Ejemplo funcional
### Ejemplo 1: El usuario pide "Construye un formulario de registro con validación"
**Prompt del usuario:** "Necesito un formulario de registro con nombre, correo electrónico, contraseña y confirmación de contraseña con validación Zod."
**La respuesta guiada por el skill produciría:**
- Un esquema Zod con refinamiento de confirmación de contraseña
- Un componente React Hook Form con zodResolver
- Visualización de errores inline con atributos aria
- Tipos TypeScript inferidos del esquema
- Validación tanto en cliente como en servidor
### Ejemplo 2: El usuario pregunta "¿Cómo manejo subidas de archivos en un formulario?"
**La respuesta guiada por el skill incluiría:**
- Esquema Zod con validación de File (tamaño, tipo)
- React Hook Form con `Controller` para el input de archivo
- Server Action que procesa FormData con el archivo
- Patrón de indicación de progreso
## Profundización
### Cómo funciona el skill
Este skill proporciona:
1. **Árbol de decisión** - Elige el patrón de formulario adecuado para el escenario
2. **Patrones de esquemas** - Recetas Zod para necesidades comunes de validación
3. **Patrones de integración** - React Hook Form + Zod, Server Actions, shadcn/ui
4. **Reglas de manejo de errores** - Mejores prácticas de accesibilidad y UX
5. **Patrones avanzados** - Multi-paso, subida de archivos, formularios optimistas
### Personalización
- Añade los patrones de la librería de componentes de formulario de tu proyecto
- Incluye reglas de validación personalizadas específicas de tu dominio
- Especifica convenciones de visualización de errores
- Añade patrones de integración con API para tu backend
### Cómo instalar
```bash
mkdir -p .claude/skills/forms-validation
# Pega el contenido de la Receta en .claude/skills/forms-validation/SKILL.md
Errores comunes
- useFormStatus debe estar en un componente hijo - Lee el estado del
<form>padre más cercano, no del formulario en el mismo componente. - zodResolver valida el esquema completo - En formularios multi-paso, pasa el esquema específico del paso al resolver.
- Las Server Actions reciben FormData, no objetos tipados - Siempre parsea con Zod en el lado del servidor.
- z.coerce.number() es necesario para inputs de formulario porque
formData.get()siempre devuelve strings. - Los inputs de archivo no pueden ser controlados - Usa
Controlleroregisterde React Hook Form con manejo manual. - defaultValues debe coincidir con la forma del esquema - Los valores por defecto faltantes hacen que React Hook Form trate los campos como no controlados.
Alternativas
| Enfoque | Cuándo usarlo |
|---|---|
| Formik | Proyectos legacy que ya usan Formik |
| Conform | Validación de formularios server-first (combina bien con Remix) |
| Validación HTML nativa | Formularios simples con requisitos básicos |
| Final Form | Alternativa ligera a React Hook Form |
| Valibot | Alternativa con bundle más pequeño que Zod |
Preguntas frecuentes
¿Cuándo debes usar un formulario con Server Action en lugar de React Hook Form?
- Cuando el formulario no requiere interacciones complejas en el cliente (campos condicionales, multi-paso, arrays dinámicos)
- Cuando quieres mejora progresiva (el formulario funciona sin JavaScript)
- Usa validación Zod dentro de la Server Action más
useActionStatepara la gestión del state
¿Cómo compartes un único esquema Zod entre validación en cliente y servidor?
Define el esquema en un archivo compartido e impórtalo en ambos lugares:
// 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>;- Impórtalo en tu componente React Hook Form para el
zodResolver - Impórtalo en tu Server Action para
safeParse
¿Cuál es el propósito de z.coerce.number() y cuándo se necesita?
formData.get()siempre devuelve strings, incluso para inputs numéricosz.coerce.number()convierte el string a número antes de validar- Sin él, Zod rechazará el valor como "not a number"
¿Por qué useFormStatus debe colocarse en un componente hijo del formulario?
useFormStatuslee el estado pending del elemento<form>padre más cercano- Si se llama en el mismo componente que renderiza el
<form>, aún no hay un formulario padre - Siempre extrae un componente
SubmitButtonseparado que viva dentro del formulario
¿Cómo tipas el valor de retorno de una Server Action para usar con useActionState?
type FormState = {
errors?: Record<string, string[]>;
message?: string;
success: boolean;
};
export async function submitContact(
prevState: FormState,
formData: FormData
): Promise<FormState> {
// ...
}- El primer argumento de la action es el state anterior, el segundo es
FormData - El tipo de retorno debe coincidir con la forma del state inicial pasado a
useActionState
¿Cómo funciona discriminatedUnion para validación condicional de formularios?
z.discriminatedUnion("method", [...])cambia las reglas de validación según un campo discriminador- Cada rama define sus propios campos requeridos (p. ej., card requiere cardNumber, PayPal requiere paypalEmail)
- TypeScript estrecha el tipo automáticamente cuando compruebas el valor del discriminador
Trampa: ¿Qué ocurre si olvidas defaultValues en React Hook Form?
- Los campos sin valores por defecto se tratan como inputs no controlados
- Esto provoca una advertencia de React por cambiar de no controlado a controlado
- Siempre proporciona
defaultValuesque coincidan con la forma completa de tu esquema Zod
¿Cómo validas subidas de archivos con Zod?
const UploadSchema = z.object({
avatar: z
.instanceof(File)
.refine((f) => f.size <= 5 * 1024 * 1024, "Máx. 5 MB")
.refine(
(f) => ["image/jpeg", "image/png"].includes(f.type),
"Solo se aceptan JPEG y PNG"
),
});- Usa
z.instanceof(File)como tipo base - Encadena llamadas
.refine()para comprobaciones de tamaño y tipo MIME
¿Cómo manejas la validación por paso en un formulario multi-paso?
- Define un esquema Zod separado para cada paso (
Step1Schema,Step2Schema, etc.) - Pasa el esquema del paso actual a
zodResolveren React Hook Form - Usa
form.trigger()para validar el paso actual antes de avanzar - En el envío final, valida con el
FullSchemafusionado
¿Cuál es el tipo TypeScript correcto para el genérico de zodResolver?
import { 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),
});- Infiere el tipo del esquema con
z.infer<typeof schema> - Pasa ese tipo inferido como genérico a
useForm<FormData>
Trampa: ¿Por qué z.coerce se comporta distinto de z.string().transform()?
z.coerce.number()coacciona la entrada antes de que se ejecute cualquier validaciónz.string().transform(Number)valida primero como string y luego transforma- Para inputs de formulario que llegan como strings,
z.coercees más simple y directo
¿Cuáles son las reglas de manejo de errores para formularios descritas en esta página?
- Muestra errores de campo inline directamente debajo del input
- Usa
aria-invalidyrole="alert"para accesibilidad - Muestra errores a nivel de servidor (red, auth) a nivel de formulario encima del botón de envío
- Valida al perder el foco, revalida al cambiar (
mode: "onTouched") - Nunca limpies el formulario ante un error