Fundamentos de Formularios y Validación
13 ejemplos para empezar con Formularios y Validación -- 8 básicos y 5 intermedios.
Requisitos previos
La mayoría de ejemplos asumen un proyecto Next.js 15+ App Router con TypeScript. Los formularios no triviales utilizan Zod y react-hook-form:
npm install zod react-hook-form @hookform/resolversConvenciones utilizadas en todo:
- Los esquemas de validación son objetos Zod -- fuente única de verdad para verificaciones en tiempo de ejecución y tipos en tiempo de compilación.
- Los formularios interactivos son Client Components (
"use client"). Los formularios nativos<form action={serverAction}>pueden permanecer en el servidor. - Los inputs controlados mantienen su
valueen React state; los inputs no controlados leen medianteFormDataoref.
¿Eligiendo un enfoque? Ver la Lista de Decisiones para elegir el patrón correcto para tu formulario antes de escribir código.
Ejemplos Básicos
1. Formulario Nativo No Controlado con FormData
El formulario React 19 más simple -- sin state, sin librerías, solo HTML y FormData.
"use client";
export default function ContactForm() {
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const data = new FormData(e.currentTarget);
console.log({
name: data.get("name"),
email: data.get("email"),
});
};
return (
<form onSubmit={handleSubmit}>
<input name="name" required />
<input name="email" type="email" required />
<button type="submit">Enviar</button>
</form>
);
}- Los inputs no controlados omiten el re-renderizado en cada pulsación de tecla -- económico y perfecto para formularios simples.
new FormData(form)lee cada input nombrado en una llamada; nunca necesitas refs individuales.required,type="email",minLengthy compañía usan la validación HTML5 integrada del navegador.- En Next.js, cambia
onSubmitpor<form action={serverAction}>para obtener mejora progresiva gratis.
Relacionado: Controlado vs No Controlado -- cuándo recurrir a cada uno | Patrones de Formularios Básicos -- plantillas de login, signup, contacto
2. Input Controlado con useState
Mantén el valor del input en React state cuando necesites validar, transformar o renderizar condicionalmente en base a él.
"use client";
import { useState } from "react";
export default function SearchBox() {
const [query, setQuery] = useState("");
return (
<div>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Buscar..."
/>
{query.length > 0 && <p>Escribiendo: {query}</p>}
</div>
);
}- Controlado =
valuees state,onChangeactualiza state -- React es el propietario del input. - Dispara un re-renderizado en cada pulsación de tecla; para 20+ campos, prefiere react-hook-form (no controlado bajo el capó).
- Excelente cuando el input impulsa otra UI: búsqueda en vivo, contadores de caracteres, input enmascarado, validación en cada cambio.
- Siempre proporciona
value={state}-- nuncadefaultValue-- o obtendrás un aviso de "controlled-to-uncontrolled".
Relacionado: Controlado vs No Controlado -- comparación completa y cuándo cambiar | React Hook Form -- formularios no controlados que escalan más allá de algunos campos
3. Validación con Esquema Zod
Define un esquema en tiempo de ejecución y analiza datos entrantes -- lanza una excepción o retorna un objeto tipado.
import { z } from "zod";
const UserSchema = z.object({
email: z.string().email("Email inválido"),
age: z.number().min(18, "Debe ser mayor de 18 años"),
});
const result = UserSchema.safeParse({ email: "ada@example.com", age: 30 });
if (!result.success) {
console.log(result.error.flatten().fieldErrors);
} else {
// result.data está completamente tipado como { email: string; age: number }
console.log(result.data);
}safeParseretorna{ success, data | error }-- úsalo cuando quieras manejar errores sintry/catch.- Los métodos encadenados (
.email(),.min(),.max()) cada uno agrega una regla y adjunta el mensaje de error. - El esquema resultante es la única fuente de verdad tanto para validación en tiempo de ejecución como para tipos TypeScript.
- Usa
parseen su lugar cuando estés seguro de que los datos son válidos y quieras que lance una excepción en caso de fallo.
Relacionado: Fundamentos de Zod -- esquemas, errores, safeParse vs parse | Tipos Zod -- cada tipo primitivo y compuesto | Transformaciones Zod -- transform, refine, preprocess
4. Inferir Tipos TypeScript desde Zod
Deriva el tipo estático directamente del esquema para que solo haya una cosa que mantener sincronizada.
import { z } from "zod";
const ProductSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1),
price: z.number().positive(),
tags: z.array(z.string()).default([]),
});
type Product = z.infer<typeof ProductSchema>;
// { id: string; name: string; price: number; tags: string[] }z.infer<typeof Schema>lee el tipo de salida del esquema -- renombra un campo una vez y ambos tipo y validador se actualizan juntos.z.inputte da el tipo de entrada (antes de transformaciones);z.outputte da el tipo de salida (después de transformaciones).- Usa este único tipo en todas partes: props de componentes, cuerpos de API, escrituras de base de datos -- sin divergencia.
- Comienza desde el esquema, no desde una interfaz escrita a mano; de lo contrario, estás escribiendo el tipo dos veces.
Relacionado: Inferencia Zod --
z.infervsz.inputvsz.output, trampas de transformación | Tipificación de Respuestas de API -- usando esquemas en límites de fetch
5. React Hook Form Básico
Escala más allá de algunos campos con formularios no controlados bajo el capó y registro por campo.
"use client";
import { useForm } from "react-hook-form";
interface FormValues {
email: string;
password: string;
}
export default function LoginForm() {
const { register, handleSubmit, formState: { errors } } = useForm<FormValues>();
const onSubmit = (data: FormValues) => {
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("email", { required: "Email requerido" })} />
{errors.email && <span>{errors.email.message}</span>}
<input
type="password"
{...register("password", { minLength: { value: 8, message: "Mín 8 caracteres" } })}
/>
{errors.password && <span>{errors.password.message}</span>}
<button type="submit">Iniciar sesión</button>
</form>
);
}register(name, rules)conecta un input al formulario, rastrea su valor y ejecuta validación -- sin state por campo.- Los inputs permanecen no controlados, por lo que escribir en un campo no re-renderiza los otros. Aceleración masiva después de ~10 campos.
handleSubmitejecuta validación primero; tu callback solo se dispara con datos limpios y tipados.- Para inputs ricos (selectores de fecha, selects de librerías de UI), envuélvelos en
Controllerde RHF en lugar deregister.
Relacionado: React Hook Form -- register, Controller, watch, reset | Patrones de Formularios Básicos -- recetas de login, signup, contacto
6. React Hook Form + Zod
Usa un esquema Zod como la única fuente de verdad para validación y tipos.
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const SignupSchema = z.object({
name: z.string().min(1, "Nombre requerido"),
email: z.string().email("Email inválido"),
age: z.coerce.number().min(18, "Debe ser mayor de 18+"),
});
type Signup = z.infer<typeof SignupSchema>;
export default function SignupForm() {
const { register, handleSubmit, formState: { errors } } = useForm<Signup>({
resolver: zodResolver(SignupSchema),
});
return (
<form onSubmit={handleSubmit((data) => console.log(data))}>
<input {...register("name")} />
{errors.name && <span>{errors.name.message}</span>}
<input {...register("email")} />
{errors.email && <span>{errors.email.message}</span>}
<input {...register("age")} />
{errors.age && <span>{errors.age.message}</span>}
<button type="submit">Registrarse</button>
</form>
);
}zodResolver(schema)conecta Zod a RHF -- los mensajes de validación aparecen enerrors.<field>.messageautomáticamente.z.coerce.number()convierte la cadena del<input>en un número, ahorrándote un paso de análisis manual.- Un esquema reemplaza dos fuentes de verdad (interfaz TS + reglas RHF inline) -- amigable con refactorización y conciso.
- Este es el recomendado por defecto para cualquier formulario con 4+ campos o validación no trivial.
Relacionado: RHF + Zod -- inmersión profunda, valores por defecto, campos dinámicos | Formulario shadcn -- mismo stack con componentes UI accesibles de shadcn
7. Server Action + Zod + useActionState
Valida en el servidor, retorna errores al formulario y rastrea estado pendiente con useActionState.
// app/contact/actions.ts
"use server";
import { z } from "zod";
const ContactSchema = z.object({
email: z.string().email(),
message: z.string().min(10),
});
type State = { ok: boolean; errors?: Record<string, string[]>; };
export async function submitContact(_prev: State | null, formData: FormData): Promise<State> {
const parsed = ContactSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) {
return { ok: false, errors: parsed.error.flatten().fieldErrors };
}
await fetch("https://api.example.com/contacts", {
method: "POST",
body: JSON.stringify(parsed.data),
});
return { ok: true };
}// app/contact/form.tsx
"use client";
import { useActionState } from "react";
import { submitContact } from "./actions";
export default function ContactForm() {
const [state, action, isPending] = useActionState(submitContact, null);
return (
<form action={action}>
<input name="email" />
{state?.errors?.email && <p>{state.errors.email[0]}</p>}
<textarea name="message" />
{state?.errors?.message && <p>{state.errors.message[0]}</p>}
<button type="submit" disabled={isPending}>
{isPending ? "Enviando..." : "Enviar"}
</button>
</form>
);
}- Valida en el servidor incluso si también validas en el cliente -- el cliente puede ser evitado.
Object.fromEntries(formData)convierteFormDataen un objeto simple que Zod puede analizar.useActionStateretorna[state, action, isPending]-- conectaactiondirectamente en el formulario.- El valor de retorno de la acción se convierte en el nuevo
state-- úsalo para renderizar errores a nivel de campo.
Relacionado: Formularios con Server Action -- patrón completo de extremo a extremo con redirecciones y revalidación | useActionState -- la API del hook | Server Actions -- el primitivo subyacente
8. Visualización de Errores Inline con ARIA
Muestra errores bajo cada campo de manera que los lectores de pantalla los anuncien.
"use client";
import { useState } from "react";
export default function EmailField() {
const [email, setEmail] = useState("");
const [touched, setTouched] = useState(false);
const error = touched && !email.includes("@") ? "Por favor ingresa un email válido" : null;
const errorId = "email-error";
return (
<div>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
onBlur={() => setTouched(true)}
aria-invalid={!!error}
aria-describedby={error ? errorId : undefined}
/>
{error && (
<p id={errorId} role="alert">
{error}
</p>
)}
</div>
);
}aria-invalidanuncia que el valor actual del input es rechazado.aria-describedbyvincula el input con su mensaje de error para que los lectores de pantalla los lean juntos.role="alert"en el error hace que el anuncio se dispare cuando el error aparece, no antes.- Difiere los errores hasta
onBluro envío para que los usuarios no sean reprendidos en medio de escribir.
Relacionado: Visualización de Errores de Formulario -- patrones inline, resumen y toast | Accesibilidad de Formularios -- ARIA, enfoque y anuncios en profundidad
Ejemplos Intermedios
9. Componente de Formulario shadcn
Usa primitivos Form de shadcn para conectar react-hook-form + Zod a UI accesible y estilizada en pocas líneas.
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import {
Form, FormControl, FormField, FormItem, FormLabel, FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
const Schema = z.object({
username: z.string().min(2).max(50),
});
export default function ProfileForm() {
const form = useForm<z.infer<typeof Schema>>({
resolver: zodResolver(Schema),
defaultValues: { username: "" },
});
return (
<Form {...form}>
<form onSubmit={form.handleSubmit((v) => console.log(v))}>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Usuario</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Guardar</Button>
</form>
</Form>
);
}- El
Formde shadcn es un envoltorio delgado que hila el contexto RHF a través de sus subcomponentes -- toda la tubería ARIA es automática. FormMessagerenderiza el mensaje de error del campo actual sin búsqueda manual deerrors.<field>.message.- Se envía como código fuente copy-paste en tu repositorio, para que puedas ajustar estilos o comportamiento sin bifurcar una librería.
- Requiere un proyecto configurado con shadcn;
npx shadcn@latest add form input buttongenera los componentes.
Relacionado: Formulario shadcn -- receta completa de formulario shadcn | Formulario shadcn (componente) -- los primitivos de UI | RHF + Zod -- el stack debajo
10. Formulario Optimista con useOptimistic
Muestra el nuevo elemento instantáneamente mientras se ejecuta la Server Action; retrocede automáticamente en caso de fallo.
"use client";
import { useOptimistic, useRef } from "react";
interface Todo { id: string; title: string; }
export default function TodoList({
todos,
addTodo,
}: {
todos: Todo[];
addTodo: (title: string) => Promise<void>;
}) {
const formRef = useRef<HTMLFormElement>(null);
const [optimistic, addOptimistic] = useOptimistic(
todos,
(state, next: Todo) => [...state, next]
);
const action = async (formData: FormData) => {
const title = formData.get("title") as string;
addOptimistic({ id: `temp-${Date.now()}`, title });
formRef.current?.reset();
await addTodo(title);
};
return (
<>
<ul>
{optimistic.map((t) => (
<li key={t.id}>{t.title}</li>
))}
</ul>
<form ref={formRef} action={action}>
<input name="title" required />
<button type="submit">Agregar</button>
</form>
</>
);
}useOptimistic(state, reducer)retorna un estado proyectado; los cambios desaparecen automáticamente si la acción lanza o el estado del servidor lo reemplaza.- El elemento optimista obtiene una ID temporal (
temp-<timestamp>) hasta que el servidor retorna la real. form.reset()limpia el input inmediatamente -- el usuario puede comenzar a escribir la siguiente entrada mientras el servidor se pone al día.- No dependas de UI optimista para acciones destructivas -- siempre espera confirmación del servidor antes de mostrar una eliminación como final.
Relacionado: Formularios Optimistas -- patrones de retroceso, UX de error | useOptimistic (hooks) -- API del hook | useOptimistic (React 19) -- el primitivo de React 19
11. Carga de Archivos con Drag-and-Drop
Acepta archivos mediante clic o arrastre, previsualízalos y valida tamaño/tipo con Zod.
"use client";
import { useState } from "react";
import { z } from "zod";
const FileSchema = z
.instanceof(File)
.refine((f) => f.size < 2 * 1024 * 1024, "Máximo 2MB")
.refine((f) => f.type.startsWith("image/"), "Solo imágenes");
export default function ImageUploader() {
const [preview, setPreview] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const handleFile = (file: File) => {
const result = FileSchema.safeParse(file);
if (!result.success) {
setError(result.error.issues[0].message);
return;
}
setError(null);
setPreview(URL.createObjectURL(file));
};
return (
<label
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
const file = e.dataTransfer.files[0];
if (file) handleFile(file);
}}
style={{ display: "block", border: "2px dashed #999", padding: "2rem" }}
>
Arrastra una imagen o haz clic para explorar
<input
type="file"
hidden
accept="image/*"
onChange={(e) => e.target.files?.[0] && handleFile(e.target.files[0])}
/>
{preview && <img src={preview} alt="preview" style={{ maxWidth: 200 }} />}
{error && <p role="alert">{error}</p>}
</label>
);
}z.instanceof(File).refine(...)te permite aplicar la API fluida de Zod a objetosFiledel navegador.URL.createObjectURL(file)te da una URL de vista previa local -- llama aURL.revokeObjectURLen desmontaje para evitar fugas en sesiones largas.onDragOverdebe llamar ae.preventDefault()o el navegador rechaza completamente el arrastre.- Para la carga en sí, envía el archivo a través de una Server Action o una URL de carga pre-firmada -- nunca POST archivos de múltiples megabytes directamente a tu capa de API sin regulación.
Relacionado: Patrones de Carga de Archivos -- drag-drop, múltiples archivos, progreso, S3 | Eventos Drag & Drop -- la API de evento subyacente
12. Formulario Multi-Paso con useReducer
Gestiona el estado del asistente -- campos, índice de paso, validación -- con acciones de reductor explícitas.
"use client";
import { useReducer } from "react";
interface State {
step: number;
data: { name: string; email: string; plan: string };
}
type Action =
| { type: "set"; field: keyof State["data"]; value: string }
| { type: "next" }
| { type: "back" }
| { type: "reset" };
const initial: State = { step: 0, data: { name: "", email: "", plan: "" } };
function reducer(state: State, action: Action): State {
switch (action.type) {
case "set":
return { ...state, data: { ...state.data, [action.field]: action.value } };
case "next":
return { ...state, step: state.step + 1 };
case "back":
return { ...state, step: Math.max(0, state.step - 1) };
case "reset":
return initial;
}
}
export default function Wizard() {
const [state, dispatch] = useReducer(reducer, initial);
return (
<div>
{state.step === 0 && (
<input
placeholder="Nombre"
value={state.data.name}
onChange={(e) => dispatch({ type: "set", field: "name", value: e.target.value })}
/>
)}
{state.step === 1 && (
<input
placeholder="Email"
value={state.data.email}
onChange={(e) => dispatch({ type: "set", field: "email", value: e.target.value })}
/>
)}
{state.step === 2 && <p>Revisión: {JSON.stringify(state.data)}</p>}
<button onClick={() => dispatch({ type: "back" })} disabled={state.step === 0}>
Atrás
</button>
<button onClick={() => dispatch({ type: "next" })} disabled={state.step === 2}>
Siguiente
</button>
</div>
);
}- El reductor es una función pura -- mismo state + acción = mismo resultado, lo que hace que sea fácil de hacer pruebas unitarias.
- Las acciones de tipificación como una unión discriminada te da autocompletado y captura invalidaciones en tiempo de compilación.
- La validación por paso pertenece en el reductor o en una guardia de transición antes de
dispatch({ type: "next" }). - Para asistentes de producción, combina esto con react-hook-form por paso y un esquema Zod por paso para la validación.
Relacionado: Formularios Multi-Paso con useReducer -- pruebas, guardias de validación, persistencia | Patrones de Formularios Complejos -- asistentes, arrays de campos, campos condicionales | useReducer -- el hook subyacente
13. Formulario Completamente Validado (Login)
Lo une todo: esquema Zod, RHF, errores accesibles, manejador de envío tipado.
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const LoginSchema = z.object({
email: z.string().email("Email inválido"),
password: z.string().min(8, "Mínimo 8 caracteres"),
});
type LoginValues = z.infer<typeof LoginSchema>;
export default function LoginForm() {
const {
register, handleSubmit, formState: { errors, isSubmitting },
} = useForm<LoginValues>({ resolver: zodResolver(LoginSchema) });
const onSubmit = async (values: LoginValues) => {
const res = await fetch("/api/login", {
method: "POST",
body: JSON.stringify(values),
});
if (!res.ok) alert("Login falló");
};
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<label htmlFor="email">Email</label>
<input
id="email"
aria-invalid={!!errors.email}
aria-describedby={errors.email ? "email-err" : undefined}
{...register("email")}
/>
{errors.email && <p id="email-err" role="alert">{errors.email.message}</p>}
<label htmlFor="password">Contraseña</label>
<input
id="password"
type="password"
aria-invalid={!!errors.password}
aria-describedby={errors.password ? "pw-err" : undefined}
{...register("password")}
/>
{errors.password && <p id="pw-err" role="alert">{errors.password.message}</p>}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Iniciando sesión..." : "Iniciar sesión"}
</button>
</form>
);
}noValidateen el<form>desactiva mensajes HTML5 del navegador para que tus errores Zod/RHF sean la única fuente de verdad.isSubmittingviene delformStatede RHF -- úsalo para desactivar el botón de envío y prevenir envíos dobles.- Empareja cada
aria-describedbycon unaidcoincidente en el elemento de error para que los lectores de pantalla los vinculen. - Ruta de upgrade: cambia la llamada
fetchpor una Server Action y cambia auseActionStatecuando quieras mejora progresiva.
Relacionado: Patrones de Formularios Básicos -- más plantillas de formularios listas para usar | Accesibilidad de Formularios -- patrones ARIA en profundidad | Lista de Decisiones -- eligiendo el enfoque correcto para un formulario dado