Saltar al contenido principal
Volver al blog

Validación de formularios con React Hook Form y Zod en Next.js 16

Ray MartínRay Martín
9 min de lectura
Validación de formularios con React Hook Form y Zod en Next.js 16

¿Por qué React Hook Form + Zod?

La validación de formularios es una de las tareas más comunes y a la vez más propensas a errores en el desarrollo web. Combinar React Hook Form con Zod en Next.js 16 ofrece una solución robusta, performante y con seguridad de tipos completa.

React Hook Form destaca por su enfoque en el rendimiento: utiliza refs no controlados en lugar de re-renderizar el componente completo con cada cambio de input. Esto significa que tu formulario se mantiene rápido incluso con decenas de campos. Zod, por su parte, es una librería de validación y parsing con soporte nativo de TypeScript que permite definir schemas de datos con una API declarativa y expresiva.

Las ventajas de esta combinación son claras:

  • Rendimiento óptimo: React Hook Form minimiza los re-renders al usar referencias no controladas, lo que resulta en formularios extremadamente rápidos.
  • Seguridad de tipos: Zod infiere tipos TypeScript directamente desde los schemas, eliminando la duplicación entre validación y tipado.
  • Validación declarativa: Los schemas de Zod describen la forma y restricciones de tus datos de manera clara y mantenible.
  • Validación compartida: El mismo schema Zod se puede usar tanto en el cliente como en el servidor con Server Actions.
  • Mensajes de error personalizados: Ambas librerías permiten personalizar los mensajes de error para cada campo y tipo de validación.
  • Ecosistema maduro: Amplia comunidad, documentación excelente y soporte activo para Next.js.

En comparación con alternativas como Formik o validaciones manuales con useState, esta combinación ofrece mejor rendimiento, menos código repetitivo y una experiencia de desarrollo superior gracias a la inferencia de tipos automática.

Instalación

Instalamos las tres dependencias necesarias: React Hook Form para la gestión del formulario, el resolver de Zod para la integración, y Zod para la definición de schemas.

bash
npm install react-hook-form @hookform/resolvers zod

Verificamos que las versiones instaladas son compatibles con React 19 y Next.js 16:

bash
# Comprobar las versiones instaladas
npm ls react-hook-form @hookform/resolvers zod

Asegúrate de tener al menos react-hook-form@7.60+, @hookform/resolvers@3.9+ y zod@4.0+ para compatibilidad completa con React 19 y las últimas características.

Crear un schema con Zod

Los schemas de Zod definen la estructura, tipos y restricciones de validación de tus datos. Una de las mayores ventajas es que el tipo TypeScript se infiere automáticamente del schema, eliminando la duplicación.

typescript
// schemas/contact.ts
import { z } from "zod";

export const contactSchema = z.object({
  name: z
    .string()
    .min(2, { message: "El nombre debe tener al menos 2 caracteres" })
    .max(100, { message: "El nombre no puede exceder los 100 caracteres" })
    .trim(),

  email: z
    .string()
    .email({ message: "Introduce un correo electrónico válido" })
    .toLowerCase(),

  phone: z
    .string()
    .regex(/^+?[0-9s-()]{7,20}$/, {
      message: "Introduce un número de teléfono válido",
    })
    .optional()
    .or(z.literal("")),

  subject: z.enum(["general", "project", "consulting", "other"], {
    errorMap: () => ({ message: "Selecciona un asunto válido" }),
  }),

  message: z
    .string()
    .min(10, { message: "El mensaje debe tener al menos 10 caracteres" })
    .max(2000, { message: "El mensaje no puede exceder los 2000 caracteres" }),

  acceptTerms: z.literal(true, {
    errorMap: () => ({ message: "Debes aceptar los términos y condiciones" }),
  }),
});

// El tipo se infiere automáticamente del schema
export type ContactFormData = z.infer<typeof contactSchema>;

// Schema para validación parcial (por ejemplo, guardar borradores)
export const contactDraftSchema = contactSchema.partial({
  message: true,
  subject: true,
  acceptTerms: true,
});

Algunas técnicas avanzadas para schemas de Zod:

typescript
// schemas/advanced.ts
import { z } from "zod";

// Validación condicional con refine
const registrationSchema = z
  .object({
    password: z
      .string()
      .min(8, "La contraseña debe tener al menos 8 caracteres")
      .regex(/[A-Z]/, "Debe contener al menos una mayúscula")
      .regex(/[0-9]/, "Debe contener al menos un número")
      .regex(/[^A-Za-z0-9]/, "Debe contener al menos un carácter especial"),
    confirmPassword: z.string(),
  })
  .refine((data) => data.password === data.confirmPassword, {
    message: "Las contraseñas no coinciden",
    path: ["confirmPassword"], // Asocia el error al campo correcto
  });

// Transformaciones con transform
const priceSchema = z
  .string()
  .transform((val) => parseFloat(val.replace(",", ".")))
  .refine((val) => !isNaN(val), "Introduce un precio válido")
  .refine((val) => val > 0, "El precio debe ser mayor que 0");

// Schema con discriminated union
const notificationSchema = z.discriminatedUnion("type", [
  z.object({
    type: z.literal("email"),
    emailAddress: z.string().email(),
  }),
  z.object({
    type: z.literal("sms"),
    phoneNumber: z.string().min(9),
  }),
  z.object({
    type: z.literal("push"),
    deviceToken: z.string().uuid(),
  }),
]);

Configurar el formulario

La integración entre React Hook Form y Zod se realiza mediante el zodResolver. Este resolver conecta automáticamente la validación del schema con el ciclo de vida del formulario.

typescript
// components/ContactForm.tsx
"use client";

import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { contactSchema, type ContactFormData } from "@/schemas/contact";

export default function ContactForm() {
  const {
    register,     // Conecta inputs al formulario
    handleSubmit,  // Maneja el envío con validación
    formState: {
      errors,      // Errores de validación por campo
      isSubmitting, // Estado de envío en curso
      isValid,     // Si el formulario es válido
      isDirty,     // Si el usuario ha modificado algún campo
      touchedFields, // Campos que el usuario ha tocado
    },
    reset,         // Reiniciar el formulario
    watch,         // Observar valores de campos en tiempo real
    setError,      // Establecer errores programáticamente
    clearErrors,   // Limpiar errores
    setValue,      // Establecer valores programáticamente
  } = useForm<ContactFormData>({
    resolver: zodResolver(contactSchema),
    defaultValues: {
      name: "",
      email: "",
      phone: "",
      subject: "general",
      message: "",
      acceptTerms: false as unknown as true,
    },
    mode: "onBlur", // Validar al salir del campo
    reValidateMode: "onChange", // Revalidar al cambiar tras primer error
  });

  async function onSubmit(data: ContactFormData) {
    try {
      const response = await fetch("/api/contact", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(data),
      });

      if (!response.ok) {
        const result = await response.json();
        // Manejar errores del servidor campo por campo
        if (result.errors) {
          Object.entries(result.errors).forEach(([field, message]) => {
            setError(field as keyof ContactFormData, {
              type: "server",
              message: message as string,
            });
          });
          return;
        }
        throw new Error(result.message || "Error al enviar el formulario");
      }

      reset(); // Limpiar el formulario tras envío exitoso
    } catch (error) {
      setError("root", {
        type: "server",
        message: "Ha ocurrido un error. Por favor, inténtalo de nuevo.",
      });
    }
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)} noValidate>
      {/* Campos del formulario aquí */}
    </form>
  );
}

Las opciones de configuración más importantes son:

  • mode: "onBlur" — Valida cuando el usuario sale de un campo. Ofrece un buen equilibrio entre feedback inmediato y no ser invasivo.
  • mode: "onChange" — Valida con cada cambio. Útil para feedback en tiempo real pero puede ser excesivo.
  • mode: "onSubmit" — Solo valida al enviar. Menos invasivo pero el usuario no recibe feedback hasta que intenta enviar.
  • reValidateMode — Define cuándo revalidar después del primer error. "onChange" permite que el error desaparezca en tiempo real al corregirlo.

Campos y mensajes de error

Cada campo del formulario se conecta mediante register y muestra mensajes de error del schema de Zod. Es fundamental incluir atributos ARIA para accesibilidad.

typescript
// Componente de campo reutilizable
"use client";

import { FieldError, UseFormRegisterReturn } from "react-hook-form";

interface FormFieldProps {
  label: string;
  id: string;
  type?: string;
  registration: UseFormRegisterReturn;
  error?: FieldError;
  required?: boolean;
  placeholder?: string;
}

export function FormField({
  label,
  id,
  type = "text",
  registration,
  error,
  required = false,
  placeholder,
}: FormFieldProps) {
  const errorId = `${id}-error`;

  return (
    <div className="mb-4">
      <label
        htmlFor={id}
        className="block text-sm font-medium text-gray-700 mb-1"
      >
        {label}
        {required && (
          <span className="text-red-500 ml-1" aria-hidden="true">
            *
          </span>
        )}
      </label>

      <input
        id={id}
        type={type}
        placeholder={placeholder}
        aria-invalid={error ? "true" : "false"}
        aria-describedby={error ? errorId : undefined}
        aria-required={required}
        className={`w-full px-4 py-2 border rounded-lg transition-colors
          focus:outline-none focus:ring-2 focus:ring-primary/50
          ${error
            ? "border-red-500 focus:ring-red-500/50"
            : "border-gray-300 focus:border-primary"
          }`}
        {...registration}
      />

      {error && (
        <p
          id={errorId}
          role="alert"
          className="mt-1 text-sm text-red-600"
        >
          {error.message}
        </p>
      )}
    </div>
  );
}

Uso del componente en el formulario:

typescript
// Dentro del formulario
<FormField
  label="Nombre completo"
  id="name"
  registration={register("name")}
  error={errors.name}
  required
  placeholder="Tu nombre"
/>

<FormField
  label="Correo electrónico"
  id="email"
  type="email"
  registration={register("email")}
  error={errors.email}
  required
  placeholder="tu@email.com"
/>

<FormField
  label="Teléfono"
  id="phone"
  type="tel"
  registration={register("phone")}
  error={errors.phone}
  placeholder="+34 600 000 000"
/>

Para campos especiales como selects y textareas, necesitas adaptar el componente:

typescript
// Campo de tipo select
<div className="mb-4">
  <label htmlFor="subject" className="block text-sm font-medium text-gray-700 mb-1">
    Asunto
    <span className="text-red-500 ml-1" aria-hidden="true">*</span>
  </label>

  <select
    id="subject"
    aria-invalid={errors.subject ? "true" : "false"}
    aria-describedby={errors.subject ? "subject-error" : undefined}
    className="w-full px-4 py-2 border rounded-lg"
    {...register("subject")}
  >
    <option value="general">Consulta general</option>
    <option value="project">Nuevo proyecto</option>
    <option value="consulting">Consultoría</option>
    <option value="other">Otro</option>
  </select>

  {errors.subject && (
    <p id="subject-error" role="alert" className="mt-1 text-sm text-red-600">
      {errors.subject.message}
    </p>
  )}
</div>

// Campo de tipo textarea
<div className="mb-4">
  <label htmlFor="message" className="block text-sm font-medium text-gray-700 mb-1">
    Mensaje
    <span className="text-red-500 ml-1" aria-hidden="true">*</span>
  </label>

  <textarea
    id="message"
    rows={5}
    aria-invalid={errors.message ? "true" : "false"}
    aria-describedby={errors.message ? "message-error" : undefined}
    className="w-full px-4 py-2 border rounded-lg resize-y"
    {...register("message")}
  />

  {errors.message && (
    <p id="message-error" role="alert" className="mt-1 text-sm text-red-600">
      {errors.message.message}
    </p>
  )}
</div>

Validación server-side con Server Actions

Next.js 16 con Server Actions permite ejecutar la validación de Zod directamente en el servidor, sin exponer la lógica de validación al cliente. Esto es esencial para la seguridad: nunca confíes únicamente en la validación del lado del cliente.

typescript
// app/actions/contact.ts
"use server";

import { contactSchema } from "@/schemas/contact";

export type ContactActionState = {
  success: boolean;
  message: string;
  errors?: Record<string, string[]>;
};

export async function submitContact(
  prevState: ContactActionState,
  formData: FormData
): Promise<ContactActionState> {
  // Extraer datos del FormData
  const rawData = {
    name: formData.get("name"),
    email: formData.get("email"),
    phone: formData.get("phone"),
    subject: formData.get("subject"),
    message: formData.get("message"),
    acceptTerms: formData.get("acceptTerms") === "on",
  };

  // Validar con el mismo schema de Zod
  const result = contactSchema.safeParse(rawData);

  if (!result.success) {
    // Formatear errores para el cliente
    const fieldErrors: Record<string, string[]> = {};
    for (const issue of result.error.issues) {
      const field = issue.path.join(".");
      if (!fieldErrors[field]) {
        fieldErrors[field] = [];
      }
      fieldErrors[field].push(issue.message);
    }

    return {
      success: false,
      message: "Por favor, corrige los errores del formulario.",
      errors: fieldErrors,
    };
  }

  // Procesar datos validados
  try {
    // Aquí enviarías el email, guardarías en base de datos, etc.
    const { name, email, message } = result.data;

    // Simular envío de email
    await sendEmail({
      to: process.env.MAILJET_TO_EMAIL!,
      subject: `Nuevo contacto de ${name}`,
      body: message,
      replyTo: email,
    });

    return {
      success: true,
      message: "Tu mensaje ha sido enviado correctamente.",
    };
  } catch (error) {
    return {
      success: false,
      message: "Error interno del servidor. Inténtalo de nuevo más tarde.",
    };
  }
}

async function sendEmail(params: {
  to: string;
  subject: string;
  body: string;
  replyTo: string;
}) {
  // Implementación con Mailjet u otro servicio
  const response = await fetch("https://api.mailjet.com/v3.1/send", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Basic ${Buffer.from(
        `${process.env.MAILJET_API_KEY}:${process.env.MAILJET_API_SECRET}`
      ).toString("base64")}`,
    },
    body: JSON.stringify({
      Messages: [
        {
          From: {
            Email: process.env.MAILJET_SENDER_EMAIL,
            Name: process.env.MAILJET_SENDER_NAME,
          },
          To: [{ Email: params.to, Name: process.env.MAILJET_TO_NAME }],
          Subject: params.subject,
          TextPart: params.body,
          ReplyTo: { Email: params.replyTo },
        },
      ],
    }),
  });

  if (!response.ok) {
    throw new Error("Error al enviar el email");
  }
}

Puedes combinar la validación del cliente (para feedback inmediato) con la del servidor (para seguridad) usando el mismo schema de Zod en ambos lados:

typescript
// Uso de Server Action con React Hook Form
"use client";

import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useActionState, useEffect } from "react";
import { contactSchema, type ContactFormData } from "@/schemas/contact";
import { submitContact, type ContactActionState } from "@/app/actions/contact";

export default function ContactFormWithServerAction() {
  const [state, formAction, isPending] = useActionState(submitContact, {
    success: false,
    message: "",
  });

  const {
    register,
    handleSubmit,
    formState: { errors },
    setError,
    reset,
  } = useForm<ContactFormData>({
    resolver: zodResolver(contactSchema),
    mode: "onBlur",
  });

  // Sincronizar errores del servidor con React Hook Form
  useEffect(() => {
    if (state.errors) {
      Object.entries(state.errors).forEach(([field, messages]) => {
        setError(field as keyof ContactFormData, {
          type: "server",
          message: messages[0],
        });
      });
    }
    if (state.success) {
      reset();
    }
  }, [state, setError, reset]);

  return (
    <form action={formAction}>
      {/* Campos del formulario */}
      <button type="submit" disabled={isPending}>
        {isPending ? "Enviando..." : "Enviar mensaje"}
      </button>

      {state.message && (
        <div
          role="status"
          aria-live="polite"
          className={state.success ? "text-green-600" : "text-red-600"}
        >
          {state.message}
        </div>
      )}
    </form>
  );
}

Formulario de contacto completo

A continuación, un ejemplo completo de un formulario de contacto que integra todas las piezas que hemos visto: schema de Zod, React Hook Form, validación en cliente y servidor, manejo de errores, estados de carga y accesibilidad completa.

typescript
// components/common/ContactForm.tsx
"use client";

import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslations } from "next-intl";
import { useState, useRef, useEffect } from "react";
import { contactSchema, type ContactFormData } from "@/schemas/contact";

export default function ContactForm() {
  const t = useTranslations("contact");
  const [submitStatus, setSubmitStatus] = useState<
    "idle" | "success" | "error"
  >("idle");
  const formRef = useRef<HTMLFormElement>(null);
  const statusRef = useRef<HTMLDivElement>(null);

  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting, isValid, isDirty },
    reset,
    setError,
    watch,
  } = useForm<ContactFormData>({
    resolver: zodResolver(contactSchema),
    defaultValues: {
      name: "",
      email: "",
      phone: "",
      subject: "general",
      message: "",
      acceptTerms: false as unknown as true,
    },
    mode: "onBlur",
    reValidateMode: "onChange",
  });

  // Contador de caracteres para el mensaje
  const messageValue = watch("message");
  const messageLength = messageValue?.length || 0;
  const maxMessageLength = 2000;

  // Enfocar el mensaje de estado cuando cambia
  useEffect(() => {
    if (submitStatus !== "idle" && statusRef.current) {
      statusRef.current.focus();
    }
  }, [submitStatus]);

  async function onSubmit(data: ContactFormData) {
    setSubmitStatus("idle");

    try {
      const response = await fetch("/api/contact", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(data),
      });

      if (!response.ok) {
        const result = await response.json();

        if (result.fieldErrors) {
          Object.entries(result.fieldErrors).forEach(([field, msg]) => {
            setError(field as keyof ContactFormData, {
              type: "server",
              message: msg as string,
            });
          });
          return;
        }

        throw new Error(result.message);
      }

      setSubmitStatus("success");
      reset();
    } catch {
      setSubmitStatus("error");
      setError("root", {
        type: "server",
        message: t("form.error"),
      });
    }
  }

  return (
    <form
      ref={formRef}
      onSubmit={handleSubmit(onSubmit)}
      noValidate
      aria-label={t("title")}
      className="max-w-lg mx-auto space-y-6"
    >
      {/* Nombre */}
      <div>
        <label htmlFor="contact-name" className="block text-sm font-medium mb-1">
          {t("form.name")}
          <span className="text-red-500 ml-1" aria-hidden="true">*</span>
        </label>
        <input
          id="contact-name"
          type="text"
          autoComplete="name"
          aria-invalid={errors.name ? "true" : "false"}
          aria-describedby={errors.name ? "name-error" : undefined}
          aria-required="true"
          className="w-full px-4 py-2 border border-gray-300 rounded-lg
            focus:outline-none focus:ring-2 focus:ring-primary/50"
          {...register("name")}
        />
        {errors.name && (
          <p id="name-error" role="alert" className="mt-1 text-sm text-red-600">
            {errors.name.message}
          </p>
        )}
      </div>

      {/* Email */}
      <div>
        <label htmlFor="contact-email" className="block text-sm font-medium mb-1">
          {t("form.email")}
          <span className="text-red-500 ml-1" aria-hidden="true">*</span>
        </label>
        <input
          id="contact-email"
          type="email"
          autoComplete="email"
          aria-invalid={errors.email ? "true" : "false"}
          aria-describedby={errors.email ? "email-error" : undefined}
          aria-required="true"
          className="w-full px-4 py-2 border border-gray-300 rounded-lg
            focus:outline-none focus:ring-2 focus:ring-primary/50"
          {...register("email")}
        />
        {errors.email && (
          <p id="email-error" role="alert" className="mt-1 text-sm text-red-600">
            {errors.email.message}
          </p>
        )}
      </div>

      {/* Teléfono (opcional) */}
      <div>
        <label htmlFor="contact-phone" className="block text-sm font-medium mb-1">
          {t("form.phone") || "Teléfono"}
          <span className="text-gray-400 text-xs ml-2">(opcional)</span>
        </label>
        <input
          id="contact-phone"
          type="tel"
          autoComplete="tel"
          aria-invalid={errors.phone ? "true" : "false"}
          aria-describedby={errors.phone ? "phone-error" : undefined}
          className="w-full px-4 py-2 border border-gray-300 rounded-lg
            focus:outline-none focus:ring-2 focus:ring-primary/50"
          {...register("phone")}
        />
        {errors.phone && (
          <p id="phone-error" role="alert" className="mt-1 text-sm text-red-600">
            {errors.phone.message}
          </p>
        )}
      </div>

      {/* Asunto */}
      <div>
        <label htmlFor="contact-subject" className="block text-sm font-medium mb-1">
          Asunto
          <span className="text-red-500 ml-1" aria-hidden="true">*</span>
        </label>
        <select
          id="contact-subject"
          aria-invalid={errors.subject ? "true" : "false"}
          aria-describedby={errors.subject ? "subject-error" : undefined}
          aria-required="true"
          className="w-full px-4 py-2 border border-gray-300 rounded-lg
            focus:outline-none focus:ring-2 focus:ring-primary/50"
          {...register("subject")}
        >
          <option value="general">Consulta general</option>
          <option value="project">Nuevo proyecto</option>
          <option value="consulting">Consultoría</option>
          <option value="other">Otro</option>
        </select>
        {errors.subject && (
          <p id="subject-error" role="alert" className="mt-1 text-sm text-red-600">
            {errors.subject.message}
          </p>
        )}
      </div>

      {/* Mensaje */}
      <div>
        <label htmlFor="contact-message" className="block text-sm font-medium mb-1">
          {t("form.message")}
          <span className="text-red-500 ml-1" aria-hidden="true">*</span>
        </label>
        <textarea
          id="contact-message"
          rows={5}
          aria-invalid={errors.message ? "true" : "false"}
          aria-describedby="message-count message-error"
          aria-required="true"
          className="w-full px-4 py-2 border border-gray-300 rounded-lg resize-y
            focus:outline-none focus:ring-2 focus:ring-primary/50"
          {...register("message")}
        />
        <div className="flex justify-between items-center mt-1">
          {errors.message ? (
            <p id="message-error" role="alert" className="text-sm text-red-600">
              {errors.message.message}
            </p>
          ) : (
            <span />
          )}
          <span
            id="message-count"
            className={`text-xs ${
              messageLength > maxMessageLength ? "text-red-600" : "text-gray-400"
            }`}
            aria-live="polite"
          >
            {messageLength}/{maxMessageLength}
          </span>
        </div>
      </div>

      {/* Términos y condiciones */}
      <div className="flex items-start gap-3">
        <input
          id="contact-terms"
          type="checkbox"
          aria-invalid={errors.acceptTerms ? "true" : "false"}
          aria-describedby={errors.acceptTerms ? "terms-error" : undefined}
          className="mt-1 h-4 w-4 rounded border-gray-300"
          {...register("acceptTerms")}
        />
        <div>
          <label htmlFor="contact-terms" className="text-sm text-gray-700">
            Acepto los términos y condiciones y la política de privacidad
          </label>
          {errors.acceptTerms && (
            <p id="terms-error" role="alert" className="mt-1 text-sm text-red-600">
              {errors.acceptTerms.message}
            </p>
          )}
        </div>
      </div>

      {/* Error global */}
      {errors.root && (
        <div role="alert" className="p-3 bg-red-50 border border-red-200 rounded-lg">
          <p className="text-sm text-red-700">{errors.root.message}</p>
        </div>
      )}

      {/* Mensaje de éxito */}
      {submitStatus === "success" && (
        <div
          ref={statusRef}
          role="status"
          tabIndex={-1}
          className="p-3 bg-green-50 border border-green-200 rounded-lg"
        >
          <p className="text-sm text-green-700">{t("form.success")}</p>
        </div>
      )}

      {/* Botón de envío */}
      <button
        type="submit"
        disabled={isSubmitting}
        aria-busy={isSubmitting}
        className="w-full bg-primary text-white py-3 px-6 rounded-lg
          font-medium transition-colors hover:bg-primary/90
          focus:outline-none focus:ring-2 focus:ring-primary/50 focus:ring-offset-2
          disabled:opacity-50 disabled:cursor-not-allowed"
      >
        {isSubmitting ? (
          <span className="flex items-center justify-center gap-2">
            <span className="animate-spin h-4 w-4 border-2 border-white border-t-transparent rounded-full" />
            Enviando...
          </span>
        ) : (
          t("form.submit")
        )}
      </button>
    </form>
  );
}

Patrones avanzados

React Hook Form ofrece funcionalidades avanzadas que permiten construir formularios dinámicos y complejos. Veamos algunos patrones que encontrarás en proyectos reales.

Campos condicionales con watch

Usa watch para mostrar u ocultar campos basándote en el valor de otros:

typescript
// Campos condicionales
"use client";

import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";

const projectSchema = z
  .object({
    projectType: z.enum(["web", "mobile", "consulting"]),
    budget: z.string().optional(),
    timeline: z.string().optional(),
    appPlatform: z.enum(["ios", "android", "both"]).optional(),
  })
  .refine(
    (data) => {
      if (data.projectType === "mobile" && !data.appPlatform) {
        return false;
      }
      return true;
    },
    {
      message: "Selecciona la plataforma para aplicaciones móviles",
      path: ["appPlatform"],
    }
  );

type ProjectFormData = z.infer<typeof projectSchema>;

export default function ProjectForm() {
  const { register, watch, formState: { errors } } = useForm<ProjectFormData>({
    resolver: zodResolver(projectSchema),
  });

  const projectType = watch("projectType");

  return (
    <form>
      <select {...register("projectType")}>
        <option value="web">Sitio web</option>
        <option value="mobile">App móvil</option>
        <option value="consulting">Consultoría</option>
      </select>

      {/* Solo visible para apps móviles */}
      {projectType === "mobile" && (
        <div>
          <label htmlFor="appPlatform">Plataforma</label>
          <select id="appPlatform" {...register("appPlatform")}>
            <option value="ios">iOS</option>
            <option value="android">Android</option>
            <option value="both">Ambas</option>
          </select>
          {errors.appPlatform && (
            <p role="alert">{errors.appPlatform.message}</p>
          )}
        </div>
      )}

      {/* Solo visible para web y móvil */}
      {(projectType === "web" || projectType === "mobile") && (
        <div>
          <label htmlFor="budget">Presupuesto estimado</label>
          <input id="budget" type="text" {...register("budget")} />
        </div>
      )}
    </form>
  );
}

Campos dinámicos con useFieldArray

useFieldArray permite añadir y eliminar campos dinámicamente. Es ideal para listas de elementos como skills, enlaces o experiencias laborales.

typescript
// Campos dinámicos con useFieldArray
"use client";

import { useForm, useFieldArray } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";

const teamSchema = z.object({
  teamName: z.string().min(2),
  members: z
    .array(
      z.object({
        name: z.string().min(2, "El nombre es obligatorio"),
        role: z.string().min(2, "El rol es obligatorio"),
        email: z.string().email("Email inválido"),
      })
    )
    .min(1, "Añade al menos un miembro")
    .max(10, "Máximo 10 miembros"),
});

type TeamFormData = z.infer<typeof teamSchema>;

export default function TeamForm() {
  const {
    register,
    control,
    handleSubmit,
    formState: { errors },
  } = useForm<TeamFormData>({
    resolver: zodResolver(teamSchema),
    defaultValues: {
      teamName: "",
      members: [{ name: "", role: "", email: "" }],
    },
  });

  const { fields, append, remove } = useFieldArray({
    control,
    name: "members",
  });

  return (
    <form onSubmit={handleSubmit((data) => console.log(data))}>
      <input {...register("teamName")} placeholder="Nombre del equipo" />

      {fields.map((field, index) => (
        <div key={field.id} className="flex gap-2 mb-2">
          <input
            {...register(`members.${index}.name`)}
            placeholder="Nombre"
            aria-label={`Nombre del miembro ${index + 1}`}
          />
          <input
            {...register(`members.${index}.role`)}
            placeholder="Rol"
            aria-label={`Rol del miembro ${index + 1}`}
          />
          <input
            {...register(`members.${index}.email`)}
            placeholder="Email"
            type="email"
            aria-label={`Email del miembro ${index + 1}`}
          />
          {fields.length > 1 && (
            <button
              type="button"
              onClick={() => remove(index)}
              aria-label={`Eliminar miembro ${index + 1}`}
            >
              Eliminar
            </button>
          )}
        </div>
      ))}

      {fields.length < 10 && (
        <button
          type="button"
          onClick={() => append({ name: "", role: "", email: "" })}
        >
          Añadir miembro
        </button>
      )}

      <button type="submit">Guardar equipo</button>
    </form>
  );
}

Accesibilidad en formularios

Los formularios son uno de los elementos más críticos para la accesibilidad. Un formulario mal implementado puede ser completamente inutilizable para personas que dependen de lectores de pantalla o navegación por teclado. A continuación, las pautas esenciales según WCAG 2.2.

  • aria-invalid: Indica al lector de pantalla que un campo tiene un error. Debe ser "true" cuando hay error y "false" (o estar ausente) cuando no lo hay.
  • aria-describedby: Conecta el campo con su mensaje de error para que el lector de pantalla anuncie el error al enfocar el campo.
  • aria-required: Indica que el campo es obligatorio. Complementa el asterisco visual con información semántica.
  • aria-busy: En el botón de envío, indica que se está procesando la solicitud.
  • role="alert": Hace que los mensajes de error sean anunciados inmediatamente por el lector de pantalla cuando aparecen.
  • aria-live="polite": Para mensajes de estado (éxito, contadores) que deben anunciarse sin interrumpir al usuario.
typescript
// Patrón accesible completo para un campo de formulario
function AccessibleField({
  name,
  label,
  error,
  helpText,
  required,
  register,
}: {
  name: string;
  label: string;
  error?: { message?: string };
  helpText?: string;
  required?: boolean;
  register: any;
}) {
  const errorId = `${name}-error`;
  const helpId = `${name}-help`;

  // Construir la lista de IDs para aria-describedby
  const describedByIds: string[] = [];
  if (helpText) describedByIds.push(helpId);
  if (error) describedByIds.push(errorId);

  return (
    <div className="mb-4">
      <label htmlFor={name} className="block text-sm font-medium mb-1">
        {label}
        {required && (
          <span className="text-red-500 ml-1" aria-hidden="true">*</span>
        )}
        {required && <span className="sr-only"> (obligatorio)</span>}
      </label>

      {helpText && (
        <p id={helpId} className="text-xs text-gray-500 mb-1">
          {helpText}
        </p>
      )}

      <input
        id={name}
        aria-invalid={error ? "true" : "false"}
        aria-required={required || undefined}
        aria-describedby={
          describedByIds.length > 0 ? describedByIds.join(" ") : undefined
        }
        className={`w-full px-4 py-2 border rounded-lg
          focus-visible:outline-none focus-visible:ring-2
          focus-visible:ring-primary/50 focus-visible:ring-offset-2
          ${error ? "border-red-500" : "border-gray-300"}`}
        {...register(name)}
      />

      {error?.message && (
        <p id={errorId} role="alert" className="mt-1 text-sm text-red-600">
          {error.message}
        </p>
      )}
    </div>
  );
}

Además de los atributos ARIA, asegúrate de cumplir estas pautas:

  1. Todos los campos deben tener un <label> asociado mediante htmlFor/id. Nunca uses solo el placeholder como etiqueta.
  2. Los errores deben ser visibles y anunciados: Usa colores, iconos y texto para los errores. No dependas solo del color.
  3. Navegación por teclado: Todos los campos e interacciones deben ser accesibles con Tab, Enter y Escape.
  4. Estados de focus-visible: Usa anillos de enfoque visibles para usuarios de teclado, sin mostrarlos al hacer clic con ratón.
  5. Mensajes de éxito: Anuncia el resultado del envío con role="status" y enfoca el mensaje programáticamente.
  6. Autocompletado: Usa atributos autoComplete apropiados (name, email, tel) para facilitar el llenado.

Buenas prácticas

Para cerrar esta guía, aquí tienes un resumen de las mejores prácticas para formularios con React Hook Form y Zod en Next.js 16:

  1. Un schema, dos validaciones: Define el schema de Zod una sola vez y úsalo tanto en el cliente (con zodResolver) como en el servidor (con safeParse en Server Actions o API routes).
  2. Valida siempre en el servidor: La validación del cliente es para UX; la del servidor es para seguridad. Nunca confíes únicamente en la validación del frontend.
  3. Usa mode: "onBlur" por defecto: Ofrece el mejor equilibrio entre feedback inmediato y experiencia de usuario no invasiva.
  4. Sanitiza los inputs: Usa .trim() y .toLowerCase() en el schema de Zod para normalizar los datos antes de validarlos.
  5. Maneja errores del servidor: Usa setError para mostrar errores que provienen del servidor en los campos correspondientes.
  6. Deshabilita el envío durante la carga: Usa isSubmitting para deshabilitar el botón y evitar envíos duplicados.
  7. Accesibilidad primero: Incluye aria-invalid, aria-describedby, role="alert" y etiquetas <label> en todos los campos sin excepción.
  8. Componentes reutilizables: Crea componentes de campo genéricos que encapsulen la lógica de registro, errores y accesibilidad para mantener la consistencia en toda la aplicación.
  9. Feedback visual claro: Diferencia visualmente entre campos válidos, inválidos y en estado neutral. Usa colores, bordes e iconos de forma consistente.
  10. Evita noValidate en desarrollo: Aunque usamos noValidate para controlar la validación con JavaScript, asegúrate de que la validación HTML nativa funciona como fallback cuando JavaScript no está disponible.

Con estas herramientas y patrones, estás preparado para construir formularios robustos, accesibles y mantenibles en tus aplicaciones Next.js 16. La combinación de React Hook Form y Zod no solo simplifica el código, sino que mejora la experiencia tanto del desarrollador como del usuario final.

Compartir:

Artículos relacionados