← CC3062

Formularios en React

Semestre 01, 2026

Motivación

Formularios sin librerías

useState · visión general

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.

useState · estado por campo

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.

useState · validación manual

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.

El problema escala rápido

3 campos

Manejable. Molesto, pero funciona.

10 campos

Un useState por campo, lógica de validación manual, manejo de errores disperso.

20 campos

Código difícil de mantener, propenso a bugs, reinventando la rueda.

Lo que necesitamos

  • Registrar campos sin un useState por cada uno
  • Validar con reglas declarativas (qué quiero), no código imperativo (cómo hacerlo)
  • Mostrar errores solo cuando corresponde (blur, submit)
  • Manejar el estado de envío (cargando, éxito, error)
  • Rendimiento: no re-renderizar toda la forma en cada tecla
Solución

React Hook Form

La librería estándar para formularios en React

¿Por qué React Hook Form?

Sin re-renders

Usa refs internamente. No re-renderiza el componente en cada keystroke.

🔗
Integración nativa

Compatible con Zod, Yup y otros esquemas de validación.

🪝
API basada en hooks

Sin componentes especiales que envuelvan el formulario.


npm install react-hook-form
        
useForm · visión general

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.

useForm · el hook

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.

useForm · register

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.

Lo que devuelve 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

Validación integrada en 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"
/>
        

Reglas de validación disponibles (1/2)

ReglaDescripciónEjemplo
requiredCampo obligatoriorequired: 'Campo requerido'
minLengthLongitud mínimaminLength: { value: 3, message: '...' }
maxLengthLongitud máximamaxLength: { value: 100, message: '...' }

Reglas de validación disponibles (2/2)

ReglaDescripciónEjemplo
min / maxValor numérico mínimo/máximomin: { value: 0, message: '...' }
patternExpresión regularpattern: { value: /regex/, message: '...' }
validateFunción customvalidate: 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.

Validación avanzada

Zod

Biblioteca de validación y definición de esquemas

Validación externa

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
        

Tipos de datos en Zod


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
        

Validaciones comunes de Zod


// 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

zodResolver · visión general

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.

zodResolver · el esquema

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).

zodResolver · la conexión

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.

zodResolver · refine

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.

Ejemplos completos

Formularios reales

Login · visión general

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.

Login · setError root

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.

Login · isSubmitting

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.

Registro · esquema con refine

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

Registro · formulario JSX

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>
  );
}
        
UX

¿Cuándo mostrar errores?

Modos de validación


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
});
        
ModoCuándo aparece el errorUX
onSubmitSolo al intentar enviarMenos disruptivo
onBlurAl salir del campoBalance recomendado
onChangeEn cada keystrokeMuy agresivo

Recomendación: onBlur — el usuario termina de escribir antes de ver el error

Resumen

Sin librería vs React Hook Form + Zod

AspectoSin libreríaReact Hook Form + Zod
EstadoUn useState por campoNinguno — RHF lo maneja
ValidaciónCódigo imperativo manualEsquema declarativo en Zod
ErroresManejar manualmenteerrors.campo.message
Relación entre camposCódigo complejorefine en el esquema
AspectoSin libreríaReact Hook Form + Zod
RendimientoRe-render en cada teclaSin re-renders innecesarios
Envío asyncManejar loading manualisSubmitting automático
Tipos (TypeScript)Sin inferenciaTipos inferidos del esquema Zod