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.
Busca en todas las páginas de la documentación
Patrones copy-paste para los formularios más comunes - login, signup y contacto - con validación Zod y una UX adecuada.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
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.
"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:
z.literal(true)setError para errores de nivel de campo y raízhtmlForzodResolver conecta 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")isSubmitting es true mientras la promesa onSubmit está pendiente - úsalo para desactivar el botón y mostrar texto de cargaz.literal(true) para casillas asegura que la casilla debe estar marcadaFormulario 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"],
});// 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>
);
}"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 - register para casillas funciona pero devuelve una cadena "on" o undefined. Solución: Usa z.literal(true) y asegúrate de que el valor de la casilla se mapee a un booleano, o usa Controller.
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.
| 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 |
z.literal(true) requiere que el valor sea exactamente true, no solo truthyerrorMap: z.literal(true, { errorMap: () => ({ message: "Debes aceptar" }) })errors.root?.messageconst 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.refine() opera en todo el objeto, por lo que puede comparar password y confirmPasswordpath: ["confirmPassword"] para adjuntar el error al campo correctoisSubmitting es true mientras la promesa onSubmit está pendientefalse cuando la promesa se resuelve o rechazasetError("root", { message: "Email o contraseña inválido" })register en una casilla devuelve "on" o undefined en lugar de un booleanoz.literal(true) de Zod espera un booleano, causando un desajuste de tipoController para casillas, o asegúrate de que el valor se mapee a un booleanofunction Field<T extends FieldValues>({
name, label, form,
}: {
name: FieldPath<T>;
label: string;
form: UseFormReturn<T>;
}) {
return <input {...form.register(name)} />;
}FieldPath<T> limita name a rutas de campos válidas del tipo de formulariotype ApiError = { field?: keyof SignupData; message: string };field a keyof SignupData asegura que solo nombres de campos válidos puedan pasarse a setErroruseState(false) rastrea si el signup fue exitosotrue y renderiza un mensaje de éxito en lugar del formulariozodResolver(Schema) adapta un esquema Zod a la interfaz del resolvedor de react-hook-formschema.safeParse() y mapea errores de Zod al formato FieldErrors de RHF@hookform/resolvers/zodRevisado por Chris St. John·Última actualización: 19 jul 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥