Fundamentos de Formularios y Validación
13 ejemplos para empezar con Formularios y Validación -- 8 básicos y 5 intermedios.
Busca en todas las páginas de la documentación
13 ejemplos para empezar con Formularios y Validación -- 8 básicos y 5 intermedios.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
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:
"use client"). Los formularios nativos <form action={serverAction}> pueden permanecer en el servidor.value en React state; los inputs no controlados leen mediante FormData o ref.¿Eligiendo un enfoque? Ver la Lista de Decisiones para elegir el patrón correcto para tu formulario antes de escribir código.
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>
);
}new FormData(form) lee cada input nombrado en una llamada; nunca necesitas refs individuales.required, type="email", minLength y compañía usan la validación HTML5 integrada del navegador.onSubmit por <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
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>
);
}value es state, onChange actualiza state -- React es el propietario del input.value={state} -- nunca defaultValue -- 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
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);
}safeParse retorna { success, data | error } -- úsalo cuando quieras manejar errores sin try/catch..email(), .min(), .max()) cada uno agrega una regla y adjunta el mensaje de error.parse en 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
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.input te da el tipo de entrada (antes de transformaciones); z.output te da el tipo de salida (después de transformaciones).Relacionado: Inferencia Zod --
z.infervsz.inputvsz.output, trampas de transformación | Tipificación de Respuestas de API -- usando esquemas en límites de fetch
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.handleSubmit ejecuta validación primero; tu callback solo se dispara con datos limpios y tipados.Controller de RHF en lugar de register.Relacionado: React Hook Form -- register, Controller, watch, reset | Patrones de Formularios Básicos -- recetas de login, signup, contacto
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 en errors.<field>.message automáticamente.z.coerce.number() convierte la cadena del <input> en un número, ahorrándote un paso de análisis manual.Relacionado: RHF + Zod -- inmersión profunda, valores por defecto, campos dinámicos | Formulario shadcn -- mismo stack con componentes UI accesibles de shadcn
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>
);
}Object.fromEntries(formData) convierte FormData en un objeto simple que Zod puede analizar.useActionState retorna [state, action, isPending] -- conecta action directamente en el formulario.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
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-invalid anuncia que el valor actual del input es rechazado.aria-describedby vincula 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.onBlur o 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
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>
);
}Form de shadcn es un envoltorio delgado que hila el contexto RHF a través de sus subcomponentes -- toda la tubería ARIA es automática.FormMessage renderiza el mensaje de error del campo actual sin búsqueda manual de errors.<field>.message.npx shadcn@latest add form input button genera los componentes.Relacionado: Formulario shadcn -- receta completa de formulario shadcn | Formulario shadcn (componente) -- los primitivos de UI | RHF + Zod -- el stack debajo
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.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.Relacionado: Formularios Optimistas -- patrones de retroceso, UX de error | useOptimistic (hooks) -- API del hook | useOptimistic (React 19) -- el primitivo de React 19
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 objetos File del navegador.URL.createObjectURL(file) te da una URL de vista previa local -- llama a URL.revokeObjectURL en desmontaje para evitar fugas en sesiones largas.onDragOver debe llamar a e.preventDefault() o el navegador rechaza completamente el arrastre.Relacionado: Patrones de Carga de Archivos -- drag-drop, múltiples archivos, progreso, S3 | Eventos Drag & Drop -- la API de evento subyacente
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>
);
}dispatch({ type: "next" }).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
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>
);
}noValidate en el <form> desactiva mensajes HTML5 del navegador para que tus errores Zod/RHF sean la única fuente de verdad.isSubmitting viene del formState de RHF -- úsalo para desactivar el botón de envío y prevenir envíos dobles.aria-describedby con una id coincidente en el elemento de error para que los lectores de pantalla los vinculen.fetch por una Server Action y cambia a useActionState cuando 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
Revisado por Chris St. John·Última actualización: 16 jul 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥