React Hook Form con Claude Code: guía segura de implementación
Aprende a crear formularios con React Hook Form, Zod, errores accesibles, estados de envío, pruebas y Claude Code.
Define el contrato del formulario antes de pedir código
React Hook Form es una biblioteca ligera para construir formularios en React. En vez de guardar cada tecla en useState, aprovecha el comportamiento nativo del navegador y ofrece APIs como register, handleSubmit y formState. Para alguien que empieza, esto aclara tres dudas importantes: dónde se recogen los valores, cuándo se ejecuta la validación y cómo se evita un segundo envío mientras la petición sigue en curso.
Claude Code puede generar el componente, el schema de Zod, la ruta de API, las pruebas y una refactorización inicial en una sola pasada. Eso ahorra tiempo, pero un formulario suele estar en una zona crítica del negocio: contacto comercial, prueba de producto, encuesta antes de comprar, registro a newsletter o edición de perfil. Si solo pides “crea un formulario”, puedes recibir una interfaz bonita que no valida en el servidor, no muestra errores accesibles o no bloquea el botón durante el envío.
Esta guía usa un formulario de consulta para explicar useForm, zodResolver, errores por campo, estado de envío, revalidación en API, pruebas y prompts seguros para Claude Code. Para patrones de React más amplios, revisa desarrollo React con Claude Code. Para diseño de schemas, acompáñalo con validación Zod con Claude Code.
Arquitectura: el schema en el centro
React Hook Form gestiona el flujo del formulario. Zod define qué datos son válidos. zodResolver, del paquete @hookform/resolvers/zod, conecta ambos para que React Hook Form ejecute el schema en el momento de validar.
flowchart TD
A["La persona escribe"] --> B["React Hook Form register"]
B --> C["zodResolver valida el schema"]
C --> D{"Datos válidos"}
D -->|No| E["Mostrar errores de campo"]
D -->|Sí| F["handleSubmit envía valores"]
F --> G["La API valida el mismo schema"]
G --> H["Guardar, notificar o sincronizar CRM"]
En palabras simples, useForm es el controlador, el schema de Zod es el reglamento y el resolver es el adaptador. Al pedir cambios a Claude Code, nombrar estas piezas reduce el riesgo de que reescriba más de lo necesario. También permite pedir “actualiza schema, opciones del select, API y pruebas al mismo tiempo”.
Para comprobar detalles actuales, usa fuentes oficiales: useForm de React Hook Form, React Hook Form Resolvers, API de Zod, referencia de React para <input>, y documentación de Claude Code: overview y commands.
Schema Zod listo para copiar
Empieza separando las reglas de validación en un archivo propio. El ejemplo incluye nombre, email, categoría, mensaje y consentimiento de contacto. z.infer genera el tipo TypeScript desde el schema, así evitas mantener una interfaz distinta de la validación real.
// src/features/inquiry/inquirySchema.ts
import { z } from "zod";
export const inquirySchema = z.object({
name: z
.string()
.trim()
.min(1, "Escribe tu nombre")
.max(80, "El nombre debe tener como máximo 80 caracteres"),
email: z
.string()
.trim()
.email("Escribe un email válido"),
category: z.enum(["consulting", "support", "billing"], {
error: "Elige una categoría",
}),
message: z
.string()
.trim()
.min(10, "Escribe al menos 10 caracteres")
.max(1000, "El mensaje debe tener como máximo 1000 caracteres"),
agreeToContact: z.boolean().refine((value) => value, {
message: "Debes aceptar que te contactemos",
}),
});
export type InquiryFormValues = z.infer<typeof inquirySchema>;
La categoría es un enum porque el valor enviado debe ser estable. En proyectos reales, ese valor puede decidir si el lead va a ventas, soporte, facturación o un flujo de CRM. En el prompt para Claude Code, conviene escribir tanto la etiqueta visible como el valor enviado: “etiqueta: Soporte técnico, valor: support”. La etiqueta puede traducirse; el valor debe mantenerse estable.
useForm para valores, errores y envío
Este componente conecta el schema con React Hook Form. mode: "onBlur" valida cuando la persona sale del campo. Para formularios de contacto, suele ser menos agresivo que mostrar errores en cada tecla. En el envío final, handleSubmit vuelve a validar antes de llamar a 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: "No pudimos enviar el formulario. Inténtalo de nuevo en un momento.",
});
}
};
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<div>
<label htmlFor="name">Nombre</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">Tema</label>
<select
id="category"
aria-invalid={errors.category ? "true" : "false"}
aria-describedby={errors.category ? "category-error" : undefined}
{...register("category")}
>
<option value="">Elige una opción</option>
<option value="consulting">Consultoría de implementación</option>
<option value="support">Soporte técnico</option>
<option value="billing">Facturación o contrato</option>
</select>
{errors.category && (
<p id="category-error" role="alert">
{errors.category.message}
</p>
)}
</div>
<div>
<label htmlFor="message">Mensaje</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")} />
Acepto que me contacten sobre esta consulta
</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 consulta"}
</button>
</form>
);
}
La parte importante no es el estilo, sino la relación entre campo y error. Cada campo usa aria-invalid, cada mensaje usa role="alert" y aria-describedby une el input con su explicación. Esto mejora accesibilidad y también hace las pruebas más claras. Para ampliar, consulta accesibilidad con Claude Code.
Revalidar también en la API
La validación del cliente mejora la experiencia, pero no es una barrera de seguridad. Una persona puede saltarse el formulario y llamar a la API directamente. Por eso la API debe reutilizar el mismo schema antes de guardar, enviar email o sincronizar un 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: Guardar en base de datos, enviar email o sincronizar CRM.
return NextResponse.json({ ok: true });
}
Al pedir esta ruta a Claude Code, sé específico: “reutiliza inquirySchema, devuelve 400 con errores por campo y deja email o CRM como TODO”. Así la primera versión es revisable. Secretos, reintentos y deduplicación deben tratarse como tareas separadas.
Pruebas para no romper el formulario
Los formularios se rompen de formas poco visibles. Como mínimo, prueba envío vacío, envío válido, fallo de servidor y desactivación del botón. Con Vitest y React Testing Library puedes verificar errores y llamada 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("muestra errores al enviar vacío", async () => {
render(<InquiryForm />);
await userEvent.click(screen.getByRole("button", { name: "Enviar consulta" }));
expect(await screen.findAllByRole("alert")).toHaveLength(5);
});
test("envía valores válidos a la API", async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response("{}", { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
render(<InquiryForm />);
await userEvent.type(screen.getByLabelText("Nombre"), "Masa");
await userEvent.type(screen.getByLabelText("Email"), "masa@example.com");
await userEvent.selectOptions(screen.getByLabelText("Tema"), "consulting");
await userEvent.type(
screen.getByLabelText("Mensaje"),
"Quiero implementar React Hook Form de forma segura.",
);
await userEvent.click(
screen.getByLabelText("Acepto que me contacten sobre esta consulta"),
);
await userEvent.click(screen.getByRole("button", { name: "Enviar consulta" }));
expect(fetchMock).toHaveBeenCalledWith(
"/api/inquiry",
expect.objectContaining({ method: "POST" }),
);
});
Puedes pedir a Claude Code que escriba primero las pruebas que fallan y después implemente. Para flujos completos, conecta esto con pruebas Playwright con Claude Code. Para métricas de negocio, registra el evento después de que la API acepte el formulario, no solo al hacer clic; esa idea encaja con analítica con Claude Code.
Prompt seguro para Claude Code
Un buen prompt de formulario incluye alcance, restricciones, comandos de verificación y no objetivos.
Implementa un formulario de consulta con React Hook Form y Zod.
Alcance:
- Edita solo src/features/inquiry y app/api/inquiry.
- Usa useForm, zodResolver y tipos TypeScript derivados del schema.
- Campos: name, email, category, message, agreeToContact.
- Muestra errores con role="alert" y aria-describedby.
- Desactiva el botón mientras isSubmitting sea true.
- Revalida el mismo schema Zod en la API.
- Añade pruebas con Vitest + Testing Library.
Verificación:
- npm test -- InquiryForm
- npm run typecheck
No hacer:
- No añadir una nueva librería UI.
- No renombrar valores existentes de category.
- No implementar email real, CRM ni secretos en esta tarea.
Para cambios pequeños, escribe el contrato completo. No digas solo “agrega otra categoría”; di “agrega la etiqueta Solicitud de formación con valor training, y actualiza enum, select, API, pruebas y mapeo de analítica”. Claude Code puede encontrar archivos relacionados, pero la definición del contrato debe venir de la persona responsable.
Casos de uso y diferencias
| Caso de uso | Estructura adecuada | Cuidado con |
|---|---|---|
| Formulario de contacto | Zod + React Hook Form + revalidación API | Medir leads completados, no clics |
| Edición de perfil | Cargar datos en defaultValues | Tras guardar, llamar reset(savedValues) |
| Encuesta antes de comprar | Combinar select, radio y checkbox | Alinear valores con producto o CRM |
| Búsqueda de administración | Validación ligera y URL query | No llamar API en cada tecla |
La regla común es separar etiqueta visual y valor enviado. Las etiquetas se traducen y se reescriben. Los valores enviados deben ser estables porque informes, automatizaciones y backend dependen de ellos.
Errores frecuentes
El primero es validar solo en el navegador. Importa el schema compartido en la API y llama a safeParse antes de procesar el payload.
El segundo es perder isSubmitting demasiado pronto. Si onSubmit no hace await del trabajo asíncrono, el botón puede habilitarse antes de tiempo.
El tercero es mostrar errores lejos del campo. Un banner superior puede ayudar, pero cada campo necesita su mensaje cercano y conectado.
El cuarto es dejar que Claude Code invente un nuevo sistema visual. Si tu proyecto ya tiene TextField, Select, Button o toast, indícalo en el prompt.
El quinto es olvidar lo que ocurre después del envío: mensaje de éxito, página de gracias, email, evento de analítica y CRM deben planificarse.
CTA de monetización
La calidad de un formulario se mide por el embudo que sostiene. Antes de refactorizar, decide el evento de negocio: descarga de PDF, lead de producto, compra de plantilla o solicitud de consultoría.
Para avanzar por tu cuenta, revisa los materiales en productos. Si quieres aplicar Claude Code a formularios de equipo, empieza por formación y consultoría. Un formulario es pequeño, pero suele ser la puerta entre contenido útil e ingresos.
Resultado probado
Masa probó esta estructura en un flujo pequeño de consultas. Lo más valioso fue centralizar el schema, porque evitó añadir una opción en el select y olvidar el valor permitido en la API. También ayudaron las pruebas de envío vacío y envío válido. Después de pedir cambios a Claude Code, esas pruebas detectaron rápido mensajes de error perdidos y una llamada fetch rota. En la práctica, mantener el formulario como contrato de entrada es más sólido que tratarlo solo como UI.
PDF gratis: cheatsheet de Claude Code
Introduce tu email y descarga una hoja con comandos, hábitos de revisión y flujos seguros.
Cuidamos tus datos y no enviamos spam.
Sobre el autor
Masa
Ingeniero enfocado en workflows prácticos con Claude Code.
Artículos relacionados
Workflow de Obsidian a CLAUDE.md con Claude Code
Convierte notas de trabajo de Obsidian en notas operativas de CLAUDE.md para no repetir contexto.
Claude Code Revenue CTA Routing: de artículos a PDF, Gumroad y consulta
Un flujo con Claude Code para dirigir lectores a PDF gratis, Gumroad o consulta según intención.
Reglas de handoff para equipos con Claude Code: evidencia, permisos, rollback e ingresos
Formato práctico para entregar trabajo de Claude Code con pruebas, permisos, rollback, PDF gratis, Gumroad y consulta.