Patrones de Formularios Básicos
Patrones copy-paste para los formularios más comunes — login, signup y contacto — con validación Zod y una UX adecuada.
Receta
Tarjeta de referencia rápida — lista para copiar-pegar.
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
// Esquema de login
const LoginSchema = z.object({
email: z.string().email("Email inválido"),
password: z.string().min(1, "Contraseña requerida"),
});
type LoginData = z.infer<typeof LoginSchema>;
function LoginForm({ onSubmit }: { onSubmit: (data: LoginData) => Promise<void> }) {
const { register, handleSubmit, formState: { errors, isSubmitting }, setError } = useForm<LoginData>({
resolver: zodResolver(LoginSchema),
});
async function handleLogin(data: LoginData) {
try {
await onSubmit(data);
} catch {
setError("root", { message: "Email o contraseña inválido" });
}
}
return (
<form onSubmit={handleSubmit(handleLogin)}>
{errors.root && <p className="text-red-600">{errors.root.message}</p>}
<input {...register("email")} type="email" placeholder="Email" />
{errors.email && <p>{errors.email.message}</p>}
<input {...register("password")} type="password" placeholder="Contraseña" />
{errors.password && <p>{errors.password.message}</p>}
<button disabled={isSubmitting}>{isSubmitting ? "Iniciando sesión..." : "Iniciar sesión"}</button>
</form>
);
}Cuándo usarlo: Cuando construyas flujos de autenticación estándar o de contacto — estos patrones cubren el 80% de los formularios en una app típica.
Ejemplo Funcional
"use client";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
// --- Formulario de Signup ---
const SignupSchema = z
.object({
name: z.string().min(2, "El nombre debe tener al menos 2 caracteres"),
email: z.string().email("Por favor ingresa un email válido"),
password: z
.string()
.min(8, "Al menos 8 caracteres")
.regex(/[A-Z]/, "Necesitas una letra mayúscula")
.regex(/[0-9]/, "Necesitas un número"),
confirmPassword: z.string(),
terms: z.literal(true, { errorMap: () => ({ message: "Debes aceptar los términos" }) }),
})
.refine((d) => d.password === d.confirmPassword, {
message: "Las contraseñas no coinciden",
path: ["confirmPassword"],
});
type SignupData = z.infer<typeof SignupSchema>;
export function SignupForm() {
const [success, setSuccess] = useState(false);
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
setError,
} = useForm<SignupData>({
resolver: zodResolver(SignupSchema),
defaultValues: { name: "", email: "", password: "", confirmPassword: "", terms: false as any },
});
async function onSubmit(data: SignupData) {
try {
const res = await fetch("/api/signup", {
method: "POST",
body: JSON.stringify(data),
headers: { "Content-Type": "application/json" },
});
if (!res.ok) {
const body = await res.json();
if (body.field) {
setError(body.field, { message: body.message });
} else {
setError("root", { message: body.message || "Algo salió mal" });
}
return;
}
setSuccess(true);
} catch {
setError("root", { message: "Error de red. Por favor intenta de nuevo." });
}
}
if (success) {
return <p className="text-green-600 font-medium">¡Cuenta creada! Revisa tu email.</p>;
}
return (
<form onSubmit={handleSubmit(onSubmit)} className="max-w-md space-y-4">
{errors.root && (
<div className="rounded bg-red-50 p-3 text-sm text-red-700">{errors.root.message}</div>
)}
<div>
<label htmlFor="name" className="block text-sm font-medium">Nombre</label>
<input id="name" {...register("name")} className="w-full rounded border p-2" />
{errors.name && <p className="text-sm text-red-600">{errors.name.message}</p>}
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium">Email</label>
<input id="email" {...register("email")} type="email" className="w-full rounded border p-2" />
{errors.email && <p className="text-sm text-red-600">{errors.email.message}</p>}
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium">Contraseña</label>
<input id="password" {...register("password")} type="password" className="w-full rounded border p-2" />
{errors.password && <p className="text-sm text-red-600">{errors.password.message}</p>}
</div>
<div>
<label htmlFor="confirm" className="block text-sm font-medium">Confirmar Contraseña</label>
<input id="confirm" {...register("confirmPassword")} type="password" className="w-full rounded border p-2" />
{errors.confirmPassword && <p className="text-sm text-red-600">{errors.confirmPassword.message}</p>}
</div>
<div className="flex items-center gap-2">
<input id="terms" type="checkbox" {...register("terms")} />
<label htmlFor="terms" className="text-sm">Acepto los términos y condiciones</label>
</div>
{errors.terms && <p className="text-sm text-red-600">{errors.terms.message}</p>}
<button
type="submit"
disabled={isSubmitting}
className="w-full rounded bg-blue-600 px-4 py-2 text-white disabled:opacity-50"
>
{isSubmitting ? "Creando cuenta..." : "Crear Cuenta"}
</button>
</form>
);
}Lo que esto demuestra:
- Formulario de signup completo con confirmación de contraseña
- Casilla de términos con validación
z.literal(true) - Manejo de errores del servidor con
setErrorpara errores de nivel de campo y raíz - Transición de estado exitosa
- Etiquetas accesibles con
htmlFor
Análisis Profundo
Cómo Funciona
zodResolverconecta el esquema Zod al pipeline de validación de react-hook-formsetError("root", ...)establece un error de nivel de formulario (p. ej., "Credenciales inválidas") no vinculado a un camposetError("email", ...)establece un error de nivel de campo de respuestas del servidor (p. ej., "Email ya está en uso")isSubmittingestruemientras la promesaonSubmitestá pendiente — úsalo para desactivar el botón y mostrar texto de cargaz.literal(true)para casillas asegura que la casilla debe estar marcada
Variaciones
Formulario de contacto con dropdown de asunto:
const ContactSchema = z.object({
name: z.string().min(1, "Nombre requerido"),
email: z.string().email(),
subject: z.enum(["general", "support", "billing", "partnership"]),
message: z.string().min(10).max(2000),
priority: z.enum(["low", "normal", "high"]).default("normal"),
});Login con botones OAuth:
function LoginPage() {
return (
<div className="space-y-4">
<LoginForm onSubmit={handleEmailLogin} />
<div className="relative text-center text-sm text-gray-500">
<span className="bg-white px-2">o continúa con</span>
</div>
<div className="flex gap-2">
<button onClick={() => signIn("google")} className="flex-1 rounded border p-2">Google</button>
<button onClick={() => signIn("github")} className="flex-1 rounded border p-2">GitHub</button>
</div>
</div>
);
}Flujo de contraseña olvidada:
const ForgotSchema = z.object({ email: z.string().email() });
const ResetSchema = z
.object({
token: z.string(),
password: z.string().min(8),
confirmPassword: z.string(),
})
.refine((d) => d.password === d.confirmPassword, {
message: "Las contraseñas no coinciden",
path: ["confirmPassword"],
});Notas de TypeScript
// Tipado de respuesta de error del servidor
type ApiError = { field?: keyof SignupData; message: string };
// setError de tipo seguro
const { setError } = useForm<SignupData>();
setError("email", { message: "En uso" }); // OK
setError("typo", { message: "..." }); // TS error
// Componente de campo de formulario reutilizable
function Field<T extends FieldValues>({
name, label, form, type = "text",
}: {
name: FieldPath<T>;
label: string;
form: UseFormReturn<T>;
type?: string;
}) {
return (
<div>
<label className="block text-sm font-medium">{label}</label>
<input type={type} {...form.register(name)} className="w-full rounded border p-2" />
{form.formState.errors[name] && (
<p className="text-sm text-red-600">{form.formState.errors[name]?.message as string}</p>
)}
</div>
);
}Gotchas
-
"Credenciales inválidas" genérico para login — Nunca reveles si el email o la contraseña fueron incorrectos. Solución: Siempre muestra "Email o contraseña inválido" como un error raíz.
-
Campo de contraseña no preservado en error — Los navegadores limpian los campos de contraseña al reenviar el formulario. Este es el comportamiento de seguridad esperado; no intentes repoblar las contraseñas.
-
Casilla con
register—registerpara casillas funciona pero devuelve una cadena"on"oundefined. Solución: Usaz.literal(true)y asegúrate de que el valor de la casilla se mapee a un booleano, o usaController. -
Rate limiting — Los formularios de login y signup son objetivos principales para ataques de fuerza bruta. Solución: Agrega rate limiting en el servidor y muestra mensajes de error apropiados.
Alternativas
| Alternativa | Úsalo Cuando | No lo Uses Cuando |
|---|---|---|
| Formularios de Server Action | Quieres mejoría progresiva sin JS del cliente | Necesitas validación instantánea de campos |
| Auth.js (NextAuth) | Necesitas autenticación completa con OAuth, sesiones, etc. | Solo necesitas un formulario de login simple |
| Clerk / Auth0 | Quieres autenticación gestionada con UI pre-construida | Necesitas control total sobre el flujo de autenticación |
| Formulario shadcn | Quieres una UI pulida con esfuerzo mínimo | Estás construyendo un formulario sin encabezado |
Preguntas Frecuentes
¿Cómo funciona z.literal(true) para validación de casillas?
z.literal(true)requiere que el valor sea exactamentetrue, no solo truthy- Úsalo para casillas de "aceptar términos" donde el usuario debe marcar la casilla
- Proporciona un mensaje de error personalizado a través de
errorMap:z.literal(true, { errorMap: () => ({ message: "Debes aceptar" }) })
¿Qué hace setError("root", ...) y cuándo deberías usarlo?
- Establece un error de nivel de formulario no vinculado a ningún campo específico
- Accede a través de
errors.root?.message - Úsalo para errores genéricos del servidor como "Email o contraseña inválido" en formularios de login
¿Cómo estableces un error de nivel de campo desde una respuesta del servidor?
const body = await res.json();
if (body.field) {
setError(body.field, { message: body.message });
}setError("email", { message: "Ya está en uso" })adjunta el error al campo de email- El error se muestra de la misma manera que un error de validación
¿Por qué usar .refine() para confirmación de contraseña en lugar de validar cada campo por separado?
.refine()opera en todo el objeto, por lo que puede compararpasswordyconfirmPassword- Los validadores de un solo campo solo ven su propio valor
- Usa
path: ["confirmPassword"]para adjuntar el error al campo correcto
¿Cuál es el propósito de isSubmitting y cómo funciona?
isSubmittingestruemientras la promesaonSubmitestá pendiente- Úsalo para desactivar el botón de envío y mostrar texto de carga como "Creando cuenta..."
- Se reinicia automáticamente a
falsecuando la promesa se resuelve o rechaza
Gotcha: ¿Por qué los formularios de login siempre deben mostrar "Email o contraseña inválido" en lugar de errores específicos?
- Revelar si el email o la contraseña fueron incorrectos ayuda a los atacantes a confirmar cuentas válidas
- Siempre usa un error raíz genérico:
setError("root", { message: "Email o contraseña inválido" }) - Esta es una práctica de seguridad recomendada para todos los formularios de autenticación
Gotcha: ¿Por qué la función register no funciona bien con casillas por defecto?
registeren una casilla devuelve"on"oundefineden lugar de un booleano- El
z.literal(true)de Zod espera un booleano, causando un desajuste de tipo - Solución: usa
Controllerpara casillas, o asegúrate de que el valor se mapee a un booleano
¿Cómo tipas un componente de campo de formulario reutilizable con genéricos de TypeScript?
function Field<T extends FieldValues>({
name, label, form,
}: {
name: FieldPath<T>;
label: string;
form: UseFormReturn<T>;
}) {
return <input {...form.register(name)} />;
}FieldPath<T>limitanamea rutas de campos válidas del tipo de formulario
¿Cómo tipas la respuesta de error del servidor para setError en TypeScript?
type ApiError = { field?: keyof SignupData; message: string };- Limitar
fieldakeyof SignupDataasegura que solo nombres de campos válidos puedan pasarse asetError
¿Cómo funciona la transición de estado exitosa en el formulario de signup?
- Un booleano
useState(false)rastrea si el signup fue exitoso - En caso de éxito, establécelo en
truey renderiza un mensaje de éxito en lugar del formulario - Todo el formulario es reemplazado, previniendo envíos dobles
¿Qué es zodResolver y por qué es necesario?
zodResolver(Schema)adapta un esquema Zod a la interfaz del resolvedor de react-hook-form- Ejecuta
schema.safeParse()y mapea errores de Zod al formatoFieldErrorsde RHF - Instálalo desde
@hookform/resolvers/zod
Relacionado
- RHF + Zod — detalles de integración de resolvedores
- Form Patterns Complex — campos multi-paso, dinámicos
- Form Error Display — patrones de mostrar errores
- Form Accessibility — ARIA y gestión de enfoque