Semestre 01, 2026
function RegistroForm() {
const [nombre, setNombre] = useState('');
const [email, setEmail] = useState('');
const [edad, setEdad] = useState('');
const [errores, setErrores] = useState({});
function validar() {
const nuevosErrores = {};
if (!nombre) nuevosErrores.nombre = 'El nombre es requerido';
if (!email.includes('@')) nuevosErrores.email = 'Email inválido';
if (parseInt(edad) < 18) nuevosErrores.edad = 'Debes ser mayor de edad';
return nuevosErrores;
}
function handleSubmit(e) {
e.preventDefault();
const errs = validar();
if (Object.keys(errs).length > 0) {
setErrores(errs);
return;
}
}
return (
<form onSubmit={handleSubmit}>
<input value={nombre} onChange={e => setNombre(e.target.value)} />
{errores.nombre && <p>{errores.nombre}</p>}
</form>
);
}
Formulario básico
Un registro con 3 campos: un useState por campo, validación manual, manejo de errores. Con 3 campos: manejable.
function RegistroForm() {
const [nombre, setNombre] = useState('');
const [email, setEmail] = useState('');
const [edad, setEdad] = useState('');
const [errores, setErrores] = useState({});
function validar() {
const nuevosErrores = {};
if (!nombre) nuevosErrores.nombre = 'El nombre es requerido';
if (!email.includes('@')) nuevosErrores.email = 'Email inválido';
if (parseInt(edad) < 18) nuevosErrores.edad = 'Debes ser mayor de edad';
return nuevosErrores;
}
function handleSubmit(e) {
e.preventDefault();
const errs = validar();
if (Object.keys(errs).length > 0) {
setErrores(errs);
return;
}
}
return (
<form onSubmit={handleSubmit}>
<input value={nombre} onChange={e => setNombre(e.target.value)} />
{errores.nombre && <p>{errores.nombre}</p>}
</form>
);
}
Un useState por campo
Con 3 campos son 4 useState. Con 10 campos, 11. La lógica de validación es manual. Los errores se manejan por separado.
function RegistroForm() {
const [nombre, setNombre] = useState('');
const [email, setEmail] = useState('');
const [edad, setEdad] = useState('');
const [errores, setErrores] = useState({});
function validar() {
const nuevosErrores = {};
if (!nombre) nuevosErrores.nombre = 'El nombre es requerido';
if (!email.includes('@')) nuevosErrores.email = 'Email inválido';
if (parseInt(edad) < 18) nuevosErrores.edad = 'Debes ser mayor de edad';
return nuevosErrores;
}
function handleSubmit(e) {
e.preventDefault();
const errs = validar();
if (Object.keys(errs).length > 0) {
setErrores(errs);
return;
}
}
return (
<form onSubmit={handleSubmit}>
<input value={nombre} onChange={e => setNombre(e.target.value)} />
{errores.nombre && <p>{errores.nombre}</p>}
</form>
);
}
Validación imperativa
Cada regla se escribe a mano. Con 20 campos y reglas complejas el código se vuelve difícil de mantener.
Manejable. Molesto, pero funciona.
Un useState por campo, lógica de validación manual, manejo de errores disperso.
Código difícil de mantener, propenso a bugs, reinventando la rueda.
useState por cada unoLa librería estándar para formularios en React
Usa refs internamente. No re-renderiza el componente en cada keystroke.
Compatible con Zod, Yup y otros esquemas de validación.
Sin componentes especiales que envuelvan el formulario.
npm install react-hook-form
import { useForm } from 'react-hook-form';
function RegistroForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm();
function onSubmit(data) {
console.log(data);
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('nombre')} placeholder="Nombre" />
{errors.nombre && <p>{errors.nombre.message}</p>}
<input {...register('email')} placeholder="Email" />
{errors.email && <p>{errors.email.message}</p>}
<button type="submit">Registrar</button>
</form>
);
}
useForm — visión general
Mismo formulario de registro. Sin useState, sin función validar manual. React Hook Form maneja todo internamente.
import { useForm } from 'react-hook-form';
function RegistroForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm();
function onSubmit(data) {
console.log(data);
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('nombre')} placeholder="Nombre" />
{errors.nombre && <p>{errors.nombre.message}</p>}
<input {...register('email')} placeholder="Email" />
{errors.email && <p>{errors.email.message}</p>}
<button type="submit">Registrar</button>
</form>
);
}
useForm devuelve
register — conecta un input con RHF.handleSubmit — valida antes de llamar a onSubmit.errors — mensajes de error por campo.
import { useForm } from 'react-hook-form';
function RegistroForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm();
function onSubmit(data) {
console.log(data);
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('nombre')} placeholder="Nombre" />
{errors.nombre && <p>{errors.nombre.message}</p>}
<input {...register('email')} placeholder="Email" />
{errors.email && <p>{errors.email.message}</p>}
<button type="submit">Registrar</button>
</form>
);
}
register y errors en el JSX
{...register('nombre')} inyecta ref, name, onChange y onBlur al input.errors.nombre.message muestra el mensaje cuando hay error.
register
const { ref, name, onChange, onBlur } = register('nombre');
// Es equivalente a pasar estas props al input:
<input
ref={ref}
name="nombre"
onChange={onChange}
onBlur={onBlur}
/>
// Por eso se usa el spread:
<input {...register('nombre')} />
El spread es solo agiliza el código, conecta el input con React Hook Form sin escribir cada prop manualmente
register
<input
{...register('nombre', {
required: 'El nombre es requerido',
minLength: { value: 2, message: 'Mínimo 2 caracteres' },
maxLength: { value: 50, message: 'Máximo 50 caracteres' },
})}
/>
<input
{...register('email', {
required: 'El email es requerido',
pattern: { value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, message: 'Formato inválido' },
})}
/>
<input
{...register('edad', {
required: true,
min: { value: 18, message: 'Debes ser mayor de edad' },
valueAsNumber: true,
})}
type="number"
/>
| Regla | Descripción | Ejemplo |
|---|---|---|
required | Campo obligatorio | required: 'Campo requerido' |
minLength | Longitud mínima | minLength: { value: 3, message: '...' } |
maxLength | Longitud máxima | maxLength: { value: 100, message: '...' } |
| Regla | Descripción | Ejemplo |
|---|---|---|
min / max | Valor numérico mínimo/máximo | min: { value: 0, message: '...' } |
pattern | Expresión regular | pattern: { value: /regex/, message: '...' } |
validate | Función custom | validate: v => v !== 'admin' || '...' |
formState
const {
register,
handleSubmit,
formState: {
errors, // errores de validación
isSubmitting, // true mientras onSubmit está ejecutándose (async)
isDirty, // true si algún campo fue modificado
isValid, // true si no hay errores
},
} = useForm();
// Deshabilitar el botón mientras se envía
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Enviando...' : 'Enviar'}
</button>
watch y setValue
const { register, handleSubmit, watch, setValue } = useForm();
// Observar el valor de un campo en tiempo real
const password = watch('password');
// Campos que dependen de otros
<input
{...register('confirmar_password', {
validate: value =>
value === password || 'Las contraseñas no coinciden',
})}
/>
// Asignar un valor programáticamente
<button type="button" onClick={() => setValue('nombre', 'Ana')}>
Rellenar nombre
</button>
watch causa re-renders.
Biblioteca de validación y definición de esquemas
Las reglas en register son para campos individuales, no pueden expresar relaciones entre campos ni lógica compleja.
Reglas en register
Cada campo define sus propias reglas. No puede validar que password === confirmar o que fecha_fin > fecha_inicio.
Esquema Zod
Define el esquema completo en un solo lugar. Soporta relaciones entre campos con refine. Amigable con TypeScript.
npm install zod @hookform/resolvers
z.string() // texto
z.number() // número
z.boolean() // true/false
z.date() // fecha
z.enum(['A', 'B']) // valor fijo de un conjunto
z.array(z.string()) // arreglo de strings
z.object({ ... }) // objeto con campos definidos
// Modificadores
z.string().optional() // puede ser undefined
z.string().nullable() // puede ser null
z.string().default('valor') // valor por defecto
// Strings
z.string().min(3).max(100)
z.string().email()
z.string().url()
z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Formato YYYY-MM-DD')
z.string().trim().min(1, 'No puede estar vacío')
// Números
z.number().int('Debe ser entero').positive('Debe ser positivo')
z.number().min(0).max(100)
// Coerción (convertir string de input a número)
z.coerce.number().min(0)
z.coerce.date()
z.coerce.number() convierte automáticamente el string que viene del <input type="number"> al tipo correcto
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const esquema = z.object({
nombre: z.string().min(2, 'Mínimo 2 caracteres'),
email: z.string().email('Email inválido'),
edad: z.coerce.number().min(18, 'Debes ser mayor de edad'),
});
function RegistroForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm({ resolver: zodResolver(esquema) });
async function onSubmit(data) {
await crearUsuario(data);
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('nombre')} placeholder="Nombre" />
{errors.nombre && <p>{errors.nombre.message}</p>}
<button disabled={isSubmitting}>Registrar</button>
</form>
);
}
Integración completa
Tres piezas: el esquema Zod, el zodResolver que conecta ambas librerías, y el componente que solo llama a register.
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const esquema = z.object({
nombre: z.string().min(2, 'Mínimo 2 caracteres'),
email: z.string().email('Email inválido'),
edad: z.coerce.number().min(18, 'Debes ser mayor de edad'),
});
function RegistroForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm({ resolver: zodResolver(esquema) });
async function onSubmit(data) {
await crearUsuario(data);
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('nombre')} placeholder="Nombre" />
{errors.nombre && <p>{errors.nombre.message}</p>}
<button disabled={isSubmitting}>Registrar</button>
</form>
);
}
Esquema Zod
Define todos los campos y sus validaciones en un solo objeto. El esquema vive fuera del componente (reutilizable y testeable).
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const esquema = z.object({
nombre: z.string().min(2, 'Mínimo 2 caracteres'),
email: z.string().email('Email inválido'),
edad: z.coerce.number().min(18, 'Debes ser mayor de edad'),
});
function RegistroForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm({ resolver: zodResolver(esquema) });
async function onSubmit(data) {
await crearUsuario(data);
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('nombre')} placeholder="Nombre" />
{errors.nombre && <p>{errors.nombre.message}</p>}
<button disabled={isSubmitting}>Registrar</button>
</form>
);
}
zodResolver
zodResolver(esquema) conecta el esquema Zod con React Hook Form. RHF delega toda la validación al resolver.
const esquema = z.object({
nombre: z.string().min(2, 'Mínimo 2 caracteres'),
email: z.string().email('Email inválido'),
password: z.string().min(8, 'Mínimo 8 caracteres'),
confirmar: z.string(),
}).refine(
data => data.password === data.confirmar,
{ message: 'Las contraseñas no coinciden', path: ['confirmar'] }
);
refine — validación cruzada
refine recibe el objeto completo y puede validar relaciones entre campos.path indica en qué campo aparece el error en formState.errors.
const esquemaLogin = z.object({
email: z.string().email('Email inválido'),
password: z.string().min(8, 'Mínimo 8 caracteres'),
});
function LoginForm() {
const {
register, handleSubmit,
formState: { errors, isSubmitting },
setError,
} = useForm({ resolver: zodResolver(esquemaLogin) });
async function onSubmit(data) {
try {
await iniciarSesion(data.email, data.password);
} catch (error) {
setError('root', { message: 'Email o contraseña incorrectos' });
}
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
{errors.root && <div>{errors.root.message}</div>}
<label>
Email
<input {...register('email')} type="email" autoComplete="email" />
{errors.email && <span>{errors.email.message}</span>}
</label>
<label>
Contraseña
<input {...register('password')} type="password" />
{errors.password && <span>{errors.password.message}</span>}
</label>
<button disabled={isSubmitting}>
{isSubmitting ? 'Entrando...' : 'Iniciar sesión'}
</button>
</form>
);
}
Formulario de login
Login con validación Zod, manejo de error del servidor y estado de envío.
const esquemaLogin = z.object({
email: z.string().email('Email inválido'),
password: z.string().min(8, 'Mínimo 8 caracteres'),
});
function LoginForm() {
const {
register, handleSubmit,
formState: { errors, isSubmitting },
setError,
} = useForm({ resolver: zodResolver(esquemaLogin) });
async function onSubmit(data) {
try {
await iniciarSesion(data.email, data.password);
} catch (error) {
setError('root', { message: 'Email o contraseña incorrectos' });
}
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
{errors.root && <div>{errors.root.message}</div>}
<label>
Email
<input {...register('email')} type="email" autoComplete="email" />
{errors.email && <span>{errors.email.message}</span>}
</label>
<label>
Contraseña
<input {...register('password')} type="password" />
{errors.password && <span>{errors.password.message}</span>}
</label>
<button disabled={isSubmitting}>
{isSubmitting ? 'Entrando...' : 'Iniciar sesión'}
</button>
</form>
);
}
setError('root', ...)
Para errores del servidor que no pertenecen a un campo específico.
Se accede con errors.root.message y se muestra fuera de los inputs.
const esquemaLogin = z.object({
email: z.string().email('Email inválido'),
password: z.string().min(8, 'Mínimo 8 caracteres'),
});
function LoginForm() {
const {
register, handleSubmit,
formState: { errors, isSubmitting },
setError,
} = useForm({ resolver: zodResolver(esquemaLogin) });
async function onSubmit(data) {
try {
await iniciarSesion(data.email, data.password);
} catch (error) {
setError('root', { message: 'Email o contraseña incorrectos' });
}
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
{errors.root && <div>{errors.root.message}</div>}
<label>
Email
<input {...register('email')} type="email" autoComplete="email" />
{errors.email && <span>{errors.email.message}</span>}
</label>
<label>
Contraseña
<input {...register('password')} type="password" />
{errors.password && <span>{errors.password.message}</span>}
</label>
<button disabled={isSubmitting}>
{isSubmitting ? 'Entrando...' : 'Iniciar sesión'}
</button>
</form>
);
}
isSubmitting automático
isSubmitting es true mientras onSubmit está ejecutándose (si es async).
Deshabilitar el botón evita envíos duplicados sin código extra.
const esquemaRegistro = z.object({
nombre: z.string().min(2, 'Mínimo 2 caracteres'),
email: z.string().email('Email inválido'),
password: z.string()
.min(8, 'Mínimo 8 caracteres')
.regex(/[A-Z]/, 'Debe contener al menos una mayúscula')
.regex(/[0-9]/, 'Debe contener al menos un número'),
confirmar: z.string(),
rol: z.enum(['estudiante', 'docente'], {
errorMap: () => ({ message: 'Selecciona un rol' }),
}),
}).refine(
data => data.password === data.confirmar,
{ message: 'Las contraseñas no coinciden', path: ['confirmar'] }
);
Las reglas de password se encadenan — Zod las evalúa todas y reporta el primer fallo
function RegistroForm() {
const { register, handleSubmit, formState: { errors } } =
useForm({ resolver: zodResolver(esquemaRegistro) });
return (
<form onSubmit={handleSubmit(console.log)}>
<input {...register('nombre')} placeholder="Nombre completo" />
{errors.nombre && <p>{errors.nombre.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>}
<input {...register('confirmar')} type="password" placeholder="Confirmar" />
{errors.confirmar && <p>{errors.confirmar.message}</p>}
<select {...register('rol')}>
<option value="">Selecciona un rol</option>
<option value="estudiante">Estudiante</option>
<option value="docente">Docente</option>
</select>
{errors.rol && <p>{errors.rol.message}</p>}
<button type="submit">Crear cuenta</button>
</form>
);
}
const { register, handleSubmit, formState: { errors } } = useForm({
resolver: zodResolver(esquema),
mode: 'onBlur', // validar al salir del campo
// mode: 'onChange', // validar en cada tecla (agresivo)
// mode: 'onSubmit', // solo al enviar (por defecto)
// mode: 'all', // onChange + onBlur
});
| Modo | Cuándo aparece el error | UX |
|---|---|---|
onSubmit | Solo al intentar enviar | Menos disruptivo |
onBlur | Al salir del campo | Balance recomendado |
onChange | En cada keystroke | Muy agresivo |
Recomendación: onBlur — el usuario termina de escribir antes de ver el error
| Aspecto | Sin librería | React Hook Form + Zod |
|---|---|---|
| Estado | Un useState por campo | Ninguno — RHF lo maneja |
| Validación | Código imperativo manual | Esquema declarativo en Zod |
| Errores | Manejar manualmente | errors.campo.message |
| Relación entre campos | Código complejo | refine en el esquema |
| Aspecto | Sin librería | React Hook Form + Zod |
|---|---|---|
| Rendimiento | Re-render en cada tecla | Sin re-renders innecesarios |
| Envío async | Manejar loading manual | isSubmitting automático |
| Tipos (TypeScript) | Sin inferencia | Tipos inferidos del esquema Zod |