Use Cases (Actualizado: 2/6/2026)

Validación de formularios con Claude Code, React Hook Form y Zod

Implementa validación con Claude Code, React Hook Form, Zod, servidor, errores API, i18n, accesibilidad y tests.

Validación de formularios con Claude Code, React Hook Form y Zod

Empieza por el contrato del formulario

La validación de formularios no es solo mostrar un mensaje cuando falta un campo. En un producto real afecta al registro de pruebas, solicitudes comerciales, reservas, pagos, onboarding y pantallas internas. Un formulario puede verse correcto y aun así fallar: permite dos envíos, manda números como texto, pierde errores del servidor o no explica el problema a usuarios que navegan con teclado o lector de pantalla.

Claude Code ayuda porque la validación es una tarea muy estructurada. React Hook Form gestiona el estado del formulario y el submit. Zod define el schema, es decir, el contrato de datos. El resolver conecta ambas piezas. La validación del servidor vuelve a comprobar el JSON recibido porque el navegador no es una frontera de seguridad. Normalizar errores significa convertir fallos de Zod, reglas de negocio y problemas de JSON en una misma forma que la UI pueda mostrar. i18n es internacionalización y accesibilidad significa que los errores son comprensibles para todos los usuarios.

Este artículo usa un formulario de contacto B2B para implementar React Hook Form, Zod, validación server-side, normalización de errores API, accesibilidad, i18n y tests. Para profundizar, revisa también React Hook Form con Claude Code y validación Zod con Claude Code. Antes de implementar, contrasta detalles con la documentación oficial: Claude Code overview, React Hook Form useForm, React Hook Form Resolvers, Zod, Next.js Route Handlers y 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 que cambian las reglas

No pidas a Claude Code simplemente “agrega validación”. Describe el contexto.

Caso de usoValidación claveError común
Prueba de SaaSEmail corporativo, número de usuarios, plan, términosNo decidir si se aceptan correos personales
Formulario de contactoNombre, email, categoría, longitud del mensaje, enlaces sospechososValidar solo en el cliente y confiar en el API
Edición de usuario adminRol, permisos, ID inmutable, campos permitidosOcultar campos en UI pero aceptarlos en servidor
Reserva o pre-pagoFecha, cantidad, teléfono, dirección, inventarioCrear reservas duplicadas con doble clic

Un prompt práctico sería:

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.

La diferencia está en pedir también la ruta de fallo. La revisión debe cubrir tipos, servidor, mensajes, doble envío, accesibilidad y tests.

Schema Zod listo para copiar

El schema usa claves de mensaje en vez de textos finales. Así el API puede devolver el mismo error y la UI traducirlo según el idioma.

// 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,
};

El campo seats es numérico a propósito. Un input HTML de tipo number puede llegar como string si se registra mal. En React Hook Form usa valueAsNumber; si recibes FormData, considera un schema separado con z.coerce.number().

Validación del servidor y errores normalizados

La validación cliente mejora la experiencia, pero no protege el sistema. La API debe tratar el body como unknown y pasar por Zod antes de tocar la lógica de negocio.

// 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);
}

Con esta forma, el cliente no necesita saber si el fallo vino de Zod, del JSON o de una regla de negocio.

Componente accesible con React Hook Form

El componente evita doble envío con isSubmitting, conecta errores con aria-describedby y devuelve errores de API al campo correcto.

// 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>
  );
}

En un producto en español, traduce también labels y botones. La estructura de error debe mantenerse.

Tests y errores habituales

// 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();
});

Los fallos más comunes son claros: validar solo en cliente, olvidar doble envío, dejar que un número sea string, no mostrar errores del servidor, usar solo color rojo sin accesibilidad y permitir que Claude Code modifique archivos ajenos al formulario.

Monetización y resultado probado

Un formulario es parte del embudo de conversión. Si una solicitud de prueba falla, se desperdicia tráfico SEO y presupuesto de anuncios. Si un formulario de contacto no explica el error, baja la calidad del lead. En formación y consultoría Claude Code podemos revisar prompts, schemas, tests y checklist de revisión sobre un código real.

En la prueba de Masa, la primera versión se veía bien, pero seats llegaba como string y el error de dominio bloqueado no aparecía en el campo email. Al añadir valueAsNumber, normalización de API y tests con Testing Library, ambos problemas quedaron cubiertos. Usar message keys también facilitó comprobar interfaces en japonés e inglés con la misma respuesta del servidor.

Resumen

Con Claude Code, la validación de formularios debe tratarse como una frontera completa: React Hook Form, Zod, servidor, errores API, accesibilidad, i18n y tests. La velocidad importa, pero la calidad se nota cuando el usuario se equivoca, el servidor rechaza datos o alguien intenta enviar dos veces.

#Claude Code #validation #forms #Zod #React
Gratis

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.

Masa

Sobre el autor

Masa

Ingeniero enfocado en workflows prácticos con Claude Code.