Módulo 21: Formularios avanzados

Objetivos del módulo

  • Entender por qué los formularios grandes piden una biblioteca y qué hace distinta a React Hook Form (RHF)
  • Registrar campos con register, validar con reglas y enviar con handleSubmit
  • Elegir cuándo se valida con mode y reValidateMode
  • Leer formState sin provocar renders de más y saber qué es su proxy
  • Observar valores con useWatch, getValues y subscribe, y cambiarlos con setValue, reset y trigger
  • Cargar datos en un formulario de edición con defaultValues asíncronos y reset
  • Describir la validación con Zod 4: formatos, coerce, transform, pipe, refine, uniones discriminadas
  • Conectar Zod con zodResolver y distinguir el tipo de entrada del tipo de salida
  • Llevar cada error del servidor al campo que le corresponde con setError
  • Integrar componentes propios con Controller y useController
  • Partir un formulario en componentes con FormProvider, useFormContext y useFormState
  • Construir listas de campos con useFieldArray
  • Crear formularios dinámicos: campos condicionales, asistentes por pasos y formularios generados
  • Comparar RHF con las Actions de React y saber combinarlos

💡 Consejo: En el módulo 08 hiciste formularios a mano y en el módulo 16 con Actions. Este módulo no los sustituye: añade una herramienta para los formularios grandes. React Hook Form y Zod no dependen de una versión concreta de React (RHF 7.91 admite desde React 16.8), así que casi todo funciona igual en cualquier versión. Solo van marcados los apartados que usan ref como prop o Actions. Las versiones usadas son react-hook-form 7.91.0, @hookform/resolvers 7.0.1 y zod 4.6.7.


1. Por qué una biblioteca de formularios

Con lo que ya sabes puedes hacer cualquier formulario. El problema aparece cuando crece: 20 campos, cada uno con su valor, su error y su marca de “ya tocado”, validación al salir del campo, otra al enviar, foco en el primer error, listas de líneas que se añaden y se quitan. Ese código se repite de un formulario a otro y siempre tiene los mismos fallos.

Además hay un coste que no se ve. Un campo controlado guarda su valor en el estado, de modo que cada tecla renderiza el formulario entero. Con tres campos da igual. Con cuarenta, y un <select> con 200 opciones, se nota al escribir.

React Hook Form resuelve las dos cosas con una idea sencilla: deja el valor en el DOM. Usa campos no controlados, como los que viste en el módulo 08, y guarda una referencia a cada uno. El valor se lee cuando hace falta, sin pasar por el estado de React.

Comparación del recorrido de una pulsación: con useState provoca un render del formulario; con React Hook Form el input guarda el valor y solo hay render si cambia algo a lo que el componente se suscribió

Figura 1. Con useState, cada tecla pasa por React. Con RHF, el valor se queda en el <input>.

📘 Concepto: Un formulario controlado tiene una sola fuente de verdad, el estado de React. Un formulario no controlado deja esa fuente en el DOM y React solo pregunta cuando lo necesita. RHF añade encima lo que le falta a lo segundo: validación, errores, estado de “modificado” y defaultValues, sin renders en cada tecla.

Prepara el proyecto

Crea un proyecto como en el módulo 01, donde se explica también qué cambia si usas PowerShell, e instala las tres bibliotecas:

npm create vite@latest modulo-21 -- --template react-ts
cd modulo-21
npm install
npm install react-hook-form @hookform/resolvers zod
npm run dev

Descarga la API de ejemplo y arráncala en otra terminal con node server.mjs. Queda en http://localhost:3000/api. Sus datos viven en memoria: si creas registros de prueba, al reiniciarla vuelven los originales.

Cada ejemplo va en su propio fichero, con una exportación por defecto, y lo montas desde src/App.tsx:

// src/App.tsx
import Ejemplo from './RendersComparados.tsx';

export default function App() {
  return <Ejemplo />;
}

Borra src/App.css y sustituye src/index.css por estas reglas, que usan todos los ejemplos:

Ver src/index.css
:root {
  font-family: 'Segoe UI', system-ui, sans-serif;
  color: #263238;
  background: #f5f6fa;
}

body {
  margin: 0;
  padding: 1.25rem;
}

form,
.tarjeta {
  background: #fff;
  border: 1px solid #c5cae9;
  border-radius: 10px;
  padding: 1rem 1.25rem;
  max-width: 620px;
  margin: 0 0 1rem;
}

h2 {
  margin: 0 0 0.75rem;
  font-size: 1.15rem;
  color: #1a237e;
}

fieldset {
  border: 1px solid #c5cae9;
  border-radius: 8px;
  margin: 0 0 0.85rem;
  padding: 0.5rem 0.85rem;
}

.campo {
  display: flex;
  flex-direction: column;
  gap: 0.2rem;
  margin-bottom: 0.75rem;
}

.campo > label {
  font-weight: 600;
  font-size: 0.92rem;
}

.fila {
  display: flex;
  gap: 0.5rem;
  align-items: center;
  flex-wrap: wrap;
  margin-bottom: 0.6rem;
}

.fila > .campo {
  flex: 1;
  min-width: 130px;
  margin-bottom: 0;
}

input:not([type='checkbox'], [type='radio']),
select,
textarea {
  font: inherit;
  padding: 0.35rem 0.5rem;
  border: 1px solid #9fa8da;
  border-radius: 6px;
  background: #fff;
}

[aria-invalid='true'] {
  border-color: #c62828 !important;
  background: #fff6f5 !important;
}

button {
  font: inherit;
  padding: 0.35rem 0.8rem;
  border: 1px solid #3f51b5;
  border-radius: 6px;
  background: #fff;
  color: #283593;
  cursor: pointer;
}

button[type='submit'] {
  background: #3f51b5;
  color: #fff;
}

button:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

.error {
  color: #c62828;
  font-size: 0.85rem;
  margin: 0;
}

.aviso {
  color: #b71c1c;
  background: #ffebee;
  border: 1px solid #ef9a9a;
  border-radius: 6px;
  padding: 0.45rem 0.7rem;
  margin: 0 0 0.75rem;
}

.exito {
  color: #1b5e20;
  background: #e8f5e9;
  border: 1px solid #a5d6a7;
  border-radius: 6px;
  padding: 0.45rem 0.7rem;
  margin: 0.75rem 0 0;
}

pre {
  background: #eceff1;
  border-radius: 6px;
  padding: 0.5rem 0.7rem;
  margin: 0.75rem 0 0;
  font-size: 0.82rem;
  overflow-x: auto;
}

.contador {
  font-weight: 700;
  color: #ff7043;
}

Varios ejemplos hablan con la API mediante esta función. Es la misma idea que en el módulo 16: convierte cualquier respuesta de error en una excepción ErrorApi con el código y los detalles.

// src/api.ts
export const API = 'http://localhost:3000/api';

export class ErrorApi extends Error {
  status: number;
  detalles: Record<string, string>;

  constructor(status: number, mensaje: string, detalles: Record<string, string> = {}) {
    super(mensaje);
    this.status = status;
    this.detalles = detalles;
  }
}

// Para no distraernos con la sesión, entramos siempre como Ana (ADMIN).
// El inicio de sesión de verdad se hace en el proyecto final.
let token: string | null = null;

async function obtenerToken(): Promise<string> {
  if (token === null) {
    const respuesta = await fetch(`${API}/auth/login`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email: 'ana.garcia@empresa.es', password: 'secreto' }),
    });
    const datos = (await respuesta.json()) as { token: string };
    token = datos.token;
  }
  return token;
}

export async function pedir<T>(ruta: string, metodo = 'GET', cuerpo?: unknown): Promise<T> {
  const cabeceras: Record<string, string> = {};
  if (metodo !== 'GET') {
    cabeceras['Content-Type'] = 'application/json';
    cabeceras.Authorization = `Bearer ${await obtenerToken()}`;
  }
  const respuesta = await fetch(`${API}${ruta}`, {
    method: metodo,
    headers: cabeceras,
    body: cuerpo === undefined ? undefined : JSON.stringify(cuerpo),
  });
  const datos: unknown = await respuesta.json();
  if (!respuesta.ok) {
    const error = datos as { mensaje: string; detalles?: Record<string, string> };
    throw new ErrorApi(respuesta.status, error.mensaje, error.detalles);
  }
  return datos as T;
}

La diferencia, medida

Este ejemplo enfrenta dos formularios iguales. Cada uno cuenta sus renders con un efecto sin dependencias que escribe el número directamente en el DOM. Si el contador usara estado, él mismo causaría renders.

// src/RendersComparados.tsx
import { useEffect, useRef, useState } from 'react';
import type { SubmitEvent } from 'react';
import { useForm } from 'react-hook-form';

// Cuenta los commits del componente y escribe el número directamente en el DOM.
// Si guardase el número en un estado, el propio contador provocaría más renders.
function useContadorRenders() {
  const total = useRef(0);
  const salida = useRef<HTMLElement>(null);
  useEffect(() => {
    total.current += 1;
    if (salida.current) {
      salida.current.textContent = String(total.current);
    }
  });
  return salida;
}

function FormularioConEstado() {
  const [nombre, setNombre] = useState('');
  const [email, setEmail] = useState('');
  const renders = useContadorRenders();

  function enviar(e: SubmitEvent<HTMLFormElement>) {
    e.preventDefault();
    console.log({ nombre, email });
  }

  return (
    <form onSubmit={enviar} aria-label="Con estado">
      <h2>Con useState</h2>
      <div className="campo">
        <label htmlFor="a1">Nombre</label>
        <input id="a1" value={nombre} onChange={(e) => setNombre(e.target.value)} />
      </div>
      <div className="campo">
        <label htmlFor="a2">Email</label>
        <input id="a2" value={email} onChange={(e) => setEmail(e.target.value)} />
      </div>
      <p>Renders: <span className="contador" ref={renders} data-prueba="estado" /></p>
    </form>
  );
}

function FormularioConRhf() {
  const { register, handleSubmit } = useForm({ defaultValues: { nombre: '', email: '' } });
  const renders = useContadorRenders();

  return (
    <form onSubmit={handleSubmit((datos) => console.log(datos))} aria-label="Con RHF">
      <h2>Con React Hook Form</h2>
      <div className="campo">
        <label htmlFor="b1">Nombre</label>
        <input id="b1" {...register('nombre')} />
      </div>
      <div className="campo">
        <label htmlFor="b2">Email</label>
        <input id="b2" {...register('email')} />
      </div>
      <p>Renders: <span className="contador" ref={renders} data-prueba="rhf" /></p>
    </form>
  );
}

export default function RendersComparados() {
  return (
    <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem', maxWidth: 760 }}>
      <FormularioConEstado />
      <FormularioConRhf />
    </div>
  );
}

En los dos se han escrito Ana García y ana@empresa.es: 24 pulsaciones en total.

Dos formularios con los mismos textos: el de useState marca 26 renders y el de React Hook Form marca 3

Figura 2. Mismas 24 pulsaciones. El formulario con estado pasa de 2 a 26 renders; el de RHF se queda en 3.

Los primeros renders son del montaje: Strict Mode ejecuta dos veces los efectos y RHF hace uno más al arrancar. Después, RHF no vuelve a renderizar nada mientras escribes.

💡 Consejo: SubmitEvent es el tipo del evento submit en @types/react 19.3, que marca FormEvent como obsoleto. Con tipos anteriores, escribe FormEvent<HTMLFormElement>. Solo cambia el nombre del tipo.


2. El primer formulario: useForm, register y handleSubmit

Tres piezas hacen casi todo el trabajo:

Pieza Qué hace
useForm(opciones) Crea el “control” del formulario, un objeto que vive fuera del render y guarda campos, valores y errores
register('nombre', reglas) Conecta un campo con ese control
handleSubmit(alAcertar, alFallar) Crea el manejador de onSubmit: valida y solo llama a alAcertar si todo está bien
// src/AltaBasica.tsx
import { useState } from 'react';
import { useForm } from 'react-hook-form';

interface DatosAlta {
  nombre: string;
  email: string;
  salario: number;
}

export default function AltaBasica() {
  const [enviado, setEnviado] = useState<DatosAlta | null>(null);
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<DatosAlta>({
    defaultValues: { nombre: '', email: '' },
  });

  return (
    <form onSubmit={handleSubmit(setEnviado)} noValidate>
      <h2>Alta de empleado</h2>

      <div className="campo">
        <label htmlFor="nombre">Nombre</label>
        <input
          id="nombre"
          aria-invalid={errors.nombre ? true : undefined}
          {...register('nombre', {
            required: 'El nombre es obligatorio',
            minLength: { value: 2, message: 'Escribe al menos 2 letras' },
          })}
        />
        {errors.nombre && <p className="error">{errors.nombre.message}</p>}
      </div>

      <div className="campo">
        <label htmlFor="email">Email</label>
        <input
          id="email"
          type="email"
          aria-invalid={errors.email ? true : undefined}
          {...register('email', {
            required: 'El email es obligatorio',
            pattern: { value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, message: 'El email no es válido' },
          })}
        />
        {errors.email && <p className="error">{errors.email.message}</p>}
      </div>

      <div className="campo">
        <label htmlFor="salario">Salario</label>
        <input
          id="salario"
          type="number"
          aria-invalid={errors.salario ? true : undefined}
          {...register('salario', {
            valueAsNumber: true,
            required: 'El salario es obligatorio',
            min: { value: 1000, message: 'El salario mínimo es 1000' },
            validate: (valor) => valor % 50 === 0 || 'Debe ser múltiplo de 50',
          })}
        />
        {errors.salario && <p className="error">{errors.salario.message}</p>}
      </div>

      <button type="submit">Guardar</button>
      {enviado && <pre>{JSON.stringify(enviado, null, 2)}</pre>}
    </form>
  );
}

Qué devuelve register

No hay magia. register es una función que devuelve las props que se esparcen sobre el campo. Si imprimes lo que devuelve, esto es lo que hay:

name: "nombre"
onChange: function
onBlur: function
ref: function

Ahí está todo el mecanismo:

  • ref entrega el nodo <input> a RHF. Sin él, RHF no puede leer el valor. Esto explica un error frecuente que veremos en la sección 12.
  • onChange y onBlur avisan de que algo ha pasado.
  • name identifica el campo. Admite rutas: contacto.email crea un objeto anidado y lineas.0.cantidad una posición de un array.

No hay value. Por eso el campo sigue siendo no controlado.

Reglas de validación

Las reglas son las de HTML con mensaje propio, más validate para lo que HTML no sabe hacer.

Regla Ejemplo Comprueba
required required: 'Obligatorio' Que tenga valor
minLength, maxLength minLength: { value: 2, message: '...' } Longitud del texto
min, max min: { value: 1000, message: '...' } Valor numérico
pattern pattern: { value: /@/, message: '...' } Expresión regular
validate validate: (v) => v % 50 === 0 \|\| 'Mensaje' Lo que quieras; devuelve true o el mensaje
deps deps: ['confirmacion'] Revalida también esos campos

validate también admite un objeto con varias funciones con nombre, y cada una puede ser asíncrona.

Qué ocurre al enviar

Flujo de handleSubmit: evento submit, isSubmitting a true, validación, y según el resultado onValid o errores con foco en el primer campo

Figura 3. El recorrido de handleSubmit. Si tu función devuelve una promesa, isSubmitting sigue en true hasta que se resuelve.

Al pulsar Guardar con el formulario vacío salen los tres errores y el foco va solo al primer campo con error, sin escribir nada de código. Con datos erróneos:

Formulario de alta con tres campos y sus mensajes de error debajo: mínimo 2 letras, email no válido y múltiplo de 50

Figura 4. Los errores de AltaBasica después de enviar datos incorrectos.

Fíjate en cuatro detalles del código:

  • noValidate en el <form> apaga las burbujas del navegador. Si no, el navegador bloquearía el envío de type="email" antes de que RHF pudiera actuar, y los mensajes saldrían en dos estilos distintos.
  • errors.nombre existe solo si el campo tiene error. Trae type (required, minLength…) y message.
  • aria-invalid se pone a true o se quita del todo. Es lo que leen los lectores de pantalla, como viste en el módulo 08. El CSS lo aprovecha para pintar el borde rojo.
  • handleSubmit(setEnviado) funciona porque setEnviado es una función que recibe los datos. Normalmente pasarás una función tuya.

⚠️ Importante: formState hay que leerlo durante el render, destructurando lo que uses. RHF solo te avisa de lo que has leído (lo explica la sección 5). Si te guardas formState en una variable y la lees más tarde dentro de un manejador, no verás datos actualizados ni tendrás renders.


3. Todos los tipos de campo

register funciona igual con cualquier campo nativo. Cambia el tipo de valor que llega.

// src/TiposCampo.tsx
import { useState } from 'react';
import { useForm } from 'react-hook-form';

interface Ficha {
  nombre: string;
  salario: number;
  fechaAlta: Date | null;
  oficio: string;
  jornada: 'completa' | 'parcial';
  teletrabajo: boolean;
  idiomas: string[];
  notas: string;
  foto: FileList | null;
}

export default function TiposCampo() {
  const [salida, setSalida] = useState('');
  const { register, handleSubmit } = useForm<Ficha>({
    defaultValues: {
      nombre: 'Ana',
      oficio: 'ANALISTA',
      jornada: 'completa',
      teletrabajo: false,
      idiomas: ['Inglés'],
      notas: '',
    },
  });

  function guardar(datos: Ficha) {
    setSalida(
      JSON.stringify({ ...datos, foto: datos.foto?.[0]?.name ?? null }, null, 2),
    );
  }

  return (
    <form onSubmit={handleSubmit(guardar)}>
      <h2>Ficha de empleado</h2>

      <div className="fila">
        <div className="campo">
          <label htmlFor="nombre">Nombre</label>
          <input id="nombre" {...register('nombre')} />
        </div>
        <div className="campo">
          <label htmlFor="salario">Salario</label>
          <input id="salario" type="number" {...register('salario', { valueAsNumber: true })} />
        </div>
        <div className="campo">
          <label htmlFor="alta">Fecha de alta</label>
          <input id="alta" type="date" {...register('fechaAlta', { valueAsDate: true })} />
        </div>
      </div>

      <div className="campo">
        <label htmlFor="oficio">Oficio</label>
        <select id="oficio" {...register('oficio')}>
          <option>ANALISTA</option>
          <option>PROGRAMADOR</option>
          <option>COMERCIAL</option>
        </select>
      </div>

      <fieldset>
        <legend>Jornada</legend>
        <label><input type="radio" value="completa" {...register('jornada')} /> Completa</label>{' '}
        <label><input type="radio" value="parcial" {...register('jornada')} /> Parcial</label>
      </fieldset>

      <fieldset>
        <legend>Idiomas</legend>
        {['Inglés', 'Francés', 'Alemán'].map((idioma) => (
          <label key={idioma}>
            <input type="checkbox" value={idioma} {...register('idiomas')} /> {idioma}{' '}
          </label>
        ))}
      </fieldset>

      <p>
        <label><input type="checkbox" {...register('teletrabajo')} /> Teletrabajo</label>
      </p>

      <div className="campo">
        <label htmlFor="notas">Notas</label>
        <textarea id="notas" rows={2} {...register('notas')} />
      </div>

      <div className="campo">
        <label htmlFor="foto">Foto</label>
        <input id="foto" type="file" accept="image/*" {...register('foto')} />
      </div>

      <button type="submit">Ver los datos</button>
      {salida && <pre>{salida}</pre>}
    </form>
  );
}

Tras rellenarlo y pulsar Ver los datos:

Ficha de empleado con campos de texto, número, fecha, select, radio, casillas, textarea y archivo, y debajo el JSON resultante

Figura 5. Cada tipo de campo con su valor. El salario es un número, los idiomas un array y teletrabajo un booleano.

Campo Registro Valor que llega
text, textarea, select register('x') string
number register('x', { valueAsNumber: true }) number, o NaN si está vacío
date register('x', { valueAsDate: true }) Date, que será inválida si está vacío
date sin opción register('x') string con formato 2026-03-01
Una sola checkbox register('x') boolean
Varias checkbox con el mismo nombre value distinto en cada una string[] con las marcadas
radio value distinto en cada una string de la marcada
file register('x') FileList

Dos cosas que conviene saber:

  • Un número vacío es NaN, no null ni 0. En la prueba con el salario vacío, JSON.stringify lo enseña como null porque así convierte NaN, pero el valor real es NaN. Con required el fallo se detecta. Sin él, NaN llegaría a tu función.
  • Sin valueAsNumber todo son cadenas, aunque el input sea type="number", como ya viste en el módulo 08. Para conversiones especiales está setValueAs: (v) => v === '' ? null : Number(v). Las tres opciones son excluyentes.

💡 Consejo: La fecha con valueAsDate se interpreta en UTC: 2026-03-01 llega como 2026-03-01T00:00:00.000Z. En una zona horaria al oeste de Greenwich, mostrarla con toLocaleDateString() enseñaría el día anterior. Si solo quieres un día del calendario, y es lo normal en una fecha de alta, guarda la cadena.


4. Cuándo se valida: mode y reValidateMode

Validar en cada tecla regaña al usuario antes de que termine de escribir. Validar solo al enviar le hace esperar demasiado. RHF deja elegir con dos opciones:

  • mode: cuándo se valida antes del primer envío. Por defecto, onSubmit.
  • reValidateMode: cuándo se valida después del primer envío. Por defecto, onChange.

Este ejemplo prueba los cinco modos sobre el mismo campo:

// src/ModosValidacion.tsx
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import type { Mode } from 'react-hook-form';

const MODOS: Mode[] = ['onSubmit', 'onBlur', 'onChange', 'onTouched', 'all'];

function FormularioEmail({ modo }: { modo: Mode }) {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm({ mode: modo, defaultValues: { email: '' } });

  return (
    <form onSubmit={handleSubmit(() => {})} noValidate>
      <div className="campo">
        <label htmlFor="email">Email</label>
        <input
          id="email"
          {...register('email', {
            required: 'Obligatorio',
            pattern: { value: /@/, message: 'Falta la arroba' },
          })}
        />
        {errors.email && <p className="error" role="alert">{errors.email.message}</p>}
      </div>
      <button type="submit">Enviar</button>
    </form>
  );
}

export default function ModosValidacion() {
  const [modo, setModo] = useState<Mode>('onSubmit');

  return (
    <>
      <div className="fila">
        {MODOS.map((m) => (
          <button key={m} aria-pressed={m === modo} onClick={() => setModo(m)}>{m}</button>
        ))}
      </div>
      {/* key: cada modo empieza con un formulario limpio */}
      <FormularioEmail key={modo} modo={modo} />
    </>
  );
}

La key es la técnica para reiniciar del módulo 05: cada modo empieza con un formulario limpio, sin errores ni campos tocados. No hace falta para que el modo cambie, porque RHF 7.91 vuelve a leer mode en cada render; se ha comprobado quitando la key.

Esta tabla recoge lo medido escribiendo un valor incorrecto:

mode Al teclear por primera vez Al salir del campo Al corregir, antes de enviar Tras enviar
onSubmit Nada Nada Nada Error, y se quita al corregir
onBlur Nada Error El error sigue hasta salir otra vez Error, y se quita al corregir
onChange Error Se mantiene Se quita al instante Error, y se quita al corregir
onTouched Nada Error Se quita al instante Error, y se quita al corregir
all Error Error Se quita al instante Error, y se quita al corregir

La columna “Tras enviar” es igual en todos porque ahí manda reValidateMode, que sigue siendo onChange.

La diferencia entre onChange y all aparece al pasar por un campo vacío sin escribir nada: al salir, onChange no valida porque no ha cambiado nada, y all sí.

💡 Consejo: onTouched suele dar la mejor experiencia: no molesta mientras escribes por primera vez, avisa al terminar, y a partir de ahí te confirma en cuanto lo arreglas. Déjalo en onSubmit en formularios muy cortos, como un inicio de sesión, y usa onChange solo si necesitas isValid al día en todo momento.


5. formState: el estado del formulario

formState responde a todas las preguntas sobre el formulario:

// src/EstadoFormulario.tsx
import { useForm } from 'react-hook-form';

interface Departamento {
  nombre: string;
  localidad: string;
}

const esperar = (ms: number) => new Promise((resolver) => setTimeout(resolver, ms));

export default function EstadoFormulario() {
  const { register, handleSubmit, reset, formState } = useForm<Departamento>({
    mode: 'onChange',
    defaultValues: { nombre: 'VENTAS', localidad: 'Valencia' },
  });
  const {
    errors, isDirty, dirtyFields, touchedFields, isValid,
    isSubmitting, isSubmitted, isSubmitSuccessful, submitCount,
  } = formState;

  async function guardar(datos: Departamento) {
    await esperar(800);
    reset(datos, { keepIsSubmitted: true, keepSubmitCount: true });
  }

  return (
    <form onSubmit={handleSubmit(guardar)}>
      <h2>Editar departamento</h2>
      <div className="campo">
        <label htmlFor="nombre">Nombre</label>
        <input
          id="nombre"
          aria-invalid={errors.nombre ? true : undefined}
          {...register('nombre', { required: 'Obligatorio' })}
        />
        {errors.nombre && <p className="error">{errors.nombre.message}</p>}
      </div>
      <div className="campo">
        <label htmlFor="localidad">Localidad</label>
        <input id="localidad" {...register('localidad')} />
      </div>

      <button type="submit" disabled={!isDirty || !isValid || isSubmitting}>
        {isSubmitting ? 'Guardando...' : 'Guardar'}
      </button>{' '}
      <button type="button" disabled={!isDirty} onClick={() => reset()}>
        Descartar cambios
      </button>

      <pre data-prueba="estado">
        {JSON.stringify(
          {
            isDirty, dirtyFields, touchedFields, isValid,
            isSubmitting, isSubmitted, isSubmitSuccessful, submitCount,
          },
          null,
          1,
        )}
      </pre>
    </form>
  );
}

Formulario de departamento con el nombre modificado y un panel con los valores de formState: isDirty true, dirtyFields y touchedFields con nombre

Figura 6. Tras cambiar el nombre y salir del campo.

Propiedad Significado
errors Errores actuales por campo
isDirty Algún valor difiere de defaultValues
dirtyFields Qué campos difieren
touchedFields Qué campos han recibido y perdido el foco
isValid No hay errores. Se calcula aunque el mode sea onSubmit
isValidating, validatingFields Hay una validación asíncrona en curso
isSubmitting Se está ejecutando el envío
isSubmitted Ya se ha enviado al menos una vez, con éxito o no
isSubmitSuccessful El último envío acabó sin errores
submitCount Cuántos envíos han terminado
isLoading Se están cargando unos defaultValues asíncronos
isReady El formulario ya ha terminado de montarse
disabled El formulario entero está deshabilitado

La secuencia real de este ejemplo al pulsar Guardar:

Antes:      isDirty true   isSubmitting false  isSubmitted false  submitCount 0
Enviando:   isDirty true   isSubmitting true   isSubmitted false  submitCount 0
Después:    isDirty false  isSubmitting false  isSubmitted true   submitCount 1

isDirty vuelve a false por la llamada a reset(datos): lo guardado se convierte en los nuevos valores por defecto. Es lo que quieres tras guardar, porque el formulario ya no tiene cambios pendientes.

Qué significa que sea un proxy

formState no es un objeto normal, sino un Proxy de JavaScript que anota qué propiedades lees. Si en el render lees errors, RHF solo te renderiza cuando cambian los errores. Si lees también isDirty, también entonces. Lo que no lees no causa renders.

Eso tiene dos consecuencias:

// Bien: lees durante el render lo que necesitas
const { errors, isDirty } = useForm().formState;

// Mal: formState se lee después, dentro de un manejador
const metodos = useForm();
function alPulsar() {
  console.log(metodos.formState.isDirty); // nadie se suscribió en el render
}

Y un componente que necesita isValid paga un render por cada cambio de ese valor. Es poco, pero si te molesta, la sección 12 enseña a aislarlo en un componente pequeño.

⚠️ Importante: No uses isDirty para decidir qué enviar a la API en un PATCH parcial: usa dirtyFields, que dice campo a campo qué ha cambiado. Y recuerda que reset() sin opciones borra también isSubmitted y submitCount. En el ejemplo se conservan con keepIsSubmitted y keepSubmitCount, y por eso Descartar cambios, que llama a reset() a secas, sí los pone a cero.


6. Leer y escribir valores

RHF no renderiza al escribir, así que un valor no está disponible “porque sí” en el render. Hay varias formas de obtenerlo, y se diferencian en quién paga el render.

Herramienta Devuelve ¿Renderiza? Para qué
getValues('x') El valor ahora mismo No Dentro de manejadores
watch('x') El valor Sí, el componente del useForm Casos rápidos; evítalo en formularios grandes
useWatch({ control, name }) El valor Solo el componente que lo llama Mostrar un dato derivado
useWatch({ control, compute }) Lo que calcule tu función Solo el componente que lo llama Derivar un valor de varios campos
<Watch control={control} ... /> Lo pasa a su render Solo ese fragmento Lo mismo que useWatch, sin crear un componente
subscribe({ formState, callback }) Nada: llama a tu función No Efectos fuera de React: guardar borradores, analítica
// src/VigilarValores.tsx
import { useEffect, useRef } from 'react';
import { useForm, useWatch } from 'react-hook-form';
import type { Control } from 'react-hook-form';

interface Nomina {
  salario: number;
  comision: number;
  irpf: number;
}

const INICIALES: Nomina = { salario: 2000, comision: 0, irpf: 15 };

function useContadorRenders() {
  const total = useRef(0);
  const salida = useRef<HTMLElement>(null);
  useEffect(() => {
    total.current += 1;
    if (salida.current) {
      salida.current.textContent = String(total.current);
    }
  });
  return salida;
}

// Solo este componente se vuelve a pintar al cambiar los campos
function Neto({ control }: { control: Control<Nomina> }) {
  const renders = useContadorRenders();
  const neto = useWatch({
    control,
    compute: ({ salario, comision, irpf }) =>
      ((salario || 0) + (comision || 0)) * (1 - (irpf || 0) / 100),
  });

  return (
    <p>
      Neto: <strong data-prueba="neto">{neto.toFixed(2)} €</strong>{' '}
      (renders del hijo: <span className="contador" ref={renders} data-prueba="hijo" />)
    </p>
  );
}

export default function VigilarValores() {
  const renders = useContadorRenders();
  const {
    register, control, getValues, setValue, setFocus, resetField, subscribe,
  } = useForm<Nomina>({ defaultValues: INICIALES });

  // Sin renders: guarda un borrador cada vez que cambia un valor
  useEffect(() => {
    return subscribe({
      formState: { values: true },
      callback: ({ values }) => {
        localStorage.setItem('borrador-nomina', JSON.stringify(values));
      },
    });
  }, [subscribe]);

  function subir() {
    const nuevo = Math.round(getValues('salario') * 1.1);
    setValue('salario', nuevo, { shouldDirty: true, shouldValidate: true });
  }

  return (
    <form onSubmit={(e) => e.preventDefault()}>
      <h2>Simulador de nómina</h2>
      <div className="fila">
        <div className="campo">
          <label htmlFor="salario">Salario</label>
          <input id="salario" type="number" {...register('salario', { valueAsNumber: true })} />
        </div>
        <div className="campo">
          <label htmlFor="comision">Comisión</label>
          <input id="comision" type="number" {...register('comision', { valueAsNumber: true })} />
        </div>
        <div className="campo">
          <label htmlFor="irpf">IRPF %</label>
          <input id="irpf" type="number" {...register('irpf', { valueAsNumber: true })} />
        </div>
      </div>

      <Neto control={control} />
      <p>Renders del formulario: <span className="contador" ref={renders} data-prueba="padre" /></p>

      <div className="fila">
        <button type="button" onClick={subir}>Subir un 10 %</button>
        <button type="button" onClick={() => resetField('salario')}>Restaurar salario</button>
        <button type="button" onClick={() => setFocus('comision', { shouldSelect: true })}>
          Ir a comisión
        </button>
      </div>
    </form>
  );
}

Simulador de nómina con tres campos, el neto calculado y dos contadores: el formulario lleva 4 renders y el hijo 11

Figura 7. El neto se recalcula en cada tecla, pero solo se renderiza el hijo.

Las medidas de esta prueba: tras teclear el salario y la comisión, el padre seguía en 3 renders y el hijo había pasado de 3 a 9. Subir un 10 % usa setValue y solo renderizó al hijo. Restaurar salario usa resetField, que además toca el estado del campo, y renderizó también al padre una vez. El borrador se guardó en localStorage sin ningún render.

Fíjate en la protección (salario || 0): mientras borras el campo, el valor es NaN, y NaN || 0 da 0. Sin ella, la pantalla enseñaría NaN €.

Cambiar el formulario desde el código

Método Efecto
setValue('x', valor, opciones) Cambia un campo. Por defecto no valida ni marca nada: usa shouldValidate, shouldDirty y shouldTouch
setValues(objeto) Cambia varios campos a la vez
reset(valores, opciones) Vuelve al estado inicial, o a unos valores nuevos que pasan a ser los de por defecto
resetField('x') Lo mismo con un solo campo
resetDefaultValues(valores) Cambia los valores por defecto sin tocar lo escrito
trigger('x') Lanza la validación y devuelve una promesa con true o false
setFocus('x', { shouldSelect }) Pone el foco en el campo y puede seleccionar su texto
clearErrors('x') Quita errores puestos a mano

💡 Consejo: React Compiler (módulo 14) trae una lista de bibliotecas incompatibles, y RHF está en ella: el compilador explica que useForm() devuelve un watch() que no se puede memoizar con seguridad, y por defecto deja sin memoizar el componente que lo usa. No rompe nada, y RHF ya renderiza poco. Es un motivo más para preferir useWatch en componentes aparte.


7. Valores iniciales y formularios de edición

defaultValues no es opcional en la práctica. Sirve para tres cosas: dar el valor inicial, calcular isDirty comparando contra él, y saber a qué volver con reset().

⚠️ Importante: RHF guarda defaultValues la primera vez. Si cambias esa prop en un render posterior, no pasa nada. Para cargar datos después tienes reset, la prop values o defaultValues asíncronos.

Un formulario de edición tiene que esperar a los datos. Esta es la forma más limpia cuando no usas una caché de datos: pasar a defaultValues una función asíncrona.

// src/EditarDepartamento.tsx
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { pedir } from './api.ts';

interface Departamento {
  deptId: number;
  nombre: string;
  localidad: string;
  presupuesto: number;
}

function Formulario({ id }: { id: number }) {
  const [guardado, setGuardado] = useState(false);
  const {
    register,
    handleSubmit,
    reset,
    formState: { errors, isLoading, isDirty, isSubmitting },
  } = useForm<Departamento>({
    // Una función asíncrona: el formulario arranca sin datos y se rellena al resolverse
    defaultValues: () => pedir<Departamento>(`/departamentos/${id}`),
  });

  async function guardar(datos: Departamento) {
    const actualizado = await pedir<Departamento>(`/departamentos/${id}`, 'PUT', datos);
    reset(actualizado); // lo guardado pasa a ser el nuevo punto de partida
    setGuardado(true);
  }

  if (isLoading) {
    return <p className="tarjeta">Cargando departamento {id}...</p>;
  }

  return (
    <form onSubmit={handleSubmit(guardar)} onChange={() => setGuardado(false)}>
      <h2>Departamento {id}</h2>
      <div className="campo">
        <label htmlFor="nombre">Nombre</label>
        <input id="nombre" {...register('nombre', { required: 'Obligatorio' })} />
        {errors.nombre && <p className="error">{errors.nombre.message}</p>}
      </div>
      <div className="campo">
        <label htmlFor="localidad">Localidad</label>
        <input id="localidad" {...register('localidad')} />
      </div>
      <div className="campo">
        <label htmlFor="presupuesto">Presupuesto</label>
        <input id="presupuesto" type="number" {...register('presupuesto', { valueAsNumber: true })} />
      </div>
      <button type="submit" disabled={!isDirty || isSubmitting}>Guardar</button>
      {guardado && <p className="exito" role="status">Cambios guardados</p>}
    </form>
  );
}

export default function EditarDepartamento() {
  const [id, setId] = useState(20);

  return (
    <>
      <div className="fila">
        {[20, 30, 40].map((d) => (
          <button key={d} aria-pressed={d === id} onClick={() => setId(d)}>Dept. {d}</button>
        ))}
      </div>
      {/* key: otro id es otro formulario, con su propia carga y su propio estado */}
      <Formulario key={id} id={id} />
    </>
  );
}

En la prueba, el formulario abre con VENTAS, guarda Bilbao como localidad y el botón vuelve a quedar deshabilitado porque reset(actualizado) deja isDirty en false. Al ir al departamento 30 y volver al 20, aparece Bilbao: la key provoca otro montaje y otra petición.

Fíjate en la key={id}, porque aquí sí es imprescindible. Sin ella se reutiliza el mismo useForm, que no vuelve a ejecutar defaultValues. En la prueba sin key, el título decía Departamento 30 y los campos seguían enseñando VENTAS, sin ningún error.

Las tres opciones

Opción Cuándo Cómo se actualiza
defaultValues: async () => ... El propio formulario carga sus datos Una vez, al montar. Da isLoading
values: datos Los datos vienen de fuera y pueden cambiar, por ejemplo de TanStack Query Cada vez que cambia la prop
reset(datos) Quieres decidir el momento: tras guardar, al pulsar un botón Cuando lo llamas

Con values, un dato nuevo pisaría lo que el usuario está escribiendo. Para conservarlo está resetOptions: { keepDirtyValues: true }:

const { data } = useQuery({ queryKey: ['departamento', id], queryFn: cargar });
const metodos = useForm<Departamento>({
  values: data,
  resetOptions: { keepDirtyValues: true },
});

💡 Consejo: La API devuelve null en campos sin valor, y un <input> no se lleva bien con null: enseñaría un campo vacío, pero isDirty y el tipo se volverían confusos. Convierte los datos al entrar (telefono: datos.telefono ?? '') y de nuevo al salir. La sección 10 hace esa vuelta con un transform.


8. Zod 4 a fondo

Las reglas de register se quedan cortas enseguida: comparar dos campos, reutilizar la validación en varios formularios, obtener los tipos de TypeScript, o validar lo mismo en el cliente y en el servidor. Para eso sirve un esquema.

Ya conoces Zod del módulo 13, donde validaba respuestas de la API. En un formulario el trabajo cambia: los datos vienen de un humano, llegan como texto, los errores son lo normal y cada mensaje tiene que ser útil.

Este programa recorre lo que usarás. Se ejecuta con node src/zod-ensayos.ts, porque Node 24 entiende TypeScript sin compilar:

// src/zod-ensayos.ts
import { z } from 'zod';

const ver = (titulo: string, valor: unknown) => console.log(titulo.padEnd(30), JSON.stringify(valor));

// 1. coerce convierte antes de validar
const Salario = z.coerce.number().positive();
ver('coerce "2500"', Salario.safeParse('2500').data);
ver('coerce "" con positive', Salario.safeParse('').error?.issues[0]?.code);
ver('coerce "" con min(0)', z.coerce.number().min(0).safeParse('').data);
ver('coerce "abc"', z.coerce.number().safeParse('abc').error?.issues[0]?.message);
ver('coerce null', z.coerce.number().safeParse(null).data);
ver('coerce.boolean "false"', z.coerce.boolean().safeParse('false').data);
ver('stringbool "false"', z.stringbool().safeParse('false').data);

// 2. Mensajes propios
const Nombre = z.string({ error: 'Falta el nombre' }).trim().min(2, { error: 'Mínimo 2 letras' });
ver('undefined', Nombre.safeParse(undefined).error?.issues[0]?.message);
ver('"  a "', Nombre.safeParse('  a ').error?.issues[0]?.message);
ver('"  Ana "', Nombre.safeParse('  Ana ').data);

// 3. El campo vacío de un input opcional
const TelMal = z.string().regex(/^\d{9}$/, 'Nueve dígitos').optional();
ver('optional con ""', TelMal.safeParse('').success);
const Tel = z.union([z.literal(''), z.string().regex(/^\d{9}$/, 'Nueve dígitos')])
  .transform((v) => v || null);
ver('union con ""', Tel.safeParse('').data);
ver('union con 600100100', Tel.safeParse('600100100').data);
ver('union con 12', Tel.safeParse('12').error?.issues.map((i) => i.message));

// 4. Reglas entre campos
const Fechas = z.object({
  inicio: z.iso.date('Fecha no válida'),
  fin: z.iso.date('Fecha no válida'),
}).refine((d) => d.fin >= d.inicio, { error: 'El fin no puede ser anterior', path: ['fin'] });
ver('refine', Fechas.safeParse({ inicio: '2026-05-01', fin: '2026-04-01' }).error?.issues);
ver('refine con un campo mal', Fechas.safeParse({ inicio: 'x', fin: '2026-04-01' }).error?.issues.map((i) => `${i.path.join('.')}: ${i.message}`));

const Rango = z.object({
  minimo: z.number('Número'),
  maximo: z.number('Número'),
}).refine((d) => d.maximo >= d.minimo, { error: 'Rango invertido', path: ['maximo'] });
ver('refine con tipo incorrecto', Rango.safeParse({ minimo: 'x', maximo: 1 }).error?.issues.map((i) => `${i.path.join('.')}: ${i.message}`));

// 5. superRefine: varios errores
const Clave = z.string().superRefine((v, ctx) => {
  if (v.length < 8) ctx.addIssue({ code: 'custom', message: 'Mínimo 8 caracteres' });
  if (!/\d/.test(v)) ctx.addIssue({ code: 'custom', message: 'Al menos un número' });
});
ver('superRefine', Clave.safeParse('abc').error?.issues.map((i) => i.message));

// 6. Tres formas de leer un error
const Persona = z.object({ nombre: z.string().min(2, 'Corto'), edad: z.number('Debe ser un número') });
const fallo = Persona.safeParse({ nombre: 'A', edad: 'x' }).error!;
ver('flattenError', z.flattenError(fallo));
ver('treeifyError', z.treeifyError(fallo));
console.log(z.prettifyError(fallo));

// 7. Unión discriminada
const Cliente = z.discriminatedUnion('tipo', [
  z.object({ tipo: z.literal('particular'), dni: z.string().min(9, 'DNI incompleto') }),
  z.object({ tipo: z.literal('empresa'), cif: z.string().min(9, 'CIF incompleto') }),
]);
ver('discriminada correcta', Cliente.safeParse({ tipo: 'empresa', cif: 'B12345678' }).success);
ver('discriminada con error', Cliente.safeParse({ tipo: 'empresa', cif: 'B1' }).error?.issues.map((i) => `${i.path.join('.')}: ${i.message}`));
ver('discriminada sin tipo', Cliente.safeParse({}).error?.issues.map((i) => `${i.path.join('.')}: ${i.code}`));

// 8. Claves desconocidas
ver('object las quita', z.object({ a: z.number() }).parse({ a: 1, b: 2 }));
ver('strictObject las rechaza', z.strictObject({ a: z.number() }).safeParse({ a: 1, b: 2 }).error?.issues[0]?.code);
ver('looseObject las deja', z.looseObject({ a: z.number() }).parse({ a: 1, b: 2 }));

// 9. Tipos distintos de entrada y salida
const Precio = z.string().transform((v) => Number(v.replace(',', '.'))).pipe(z.number('No es un número').min(0));
type Entrada = z.input<typeof Precio>; // string
type Salida = z.output<typeof Precio>; // number
const entrada: Entrada = '12,50';
const salida: Salida = Precio.parse(entrada);
ver('transform y pipe', salida);
ver('transform y pipe, mal', Precio.safeParse('abc').error?.issues.map((i) => i.message));

// 10. Valores por defecto
ver('default con undefined', z.string().default('ACTIVO').parse(undefined));
ver('default con ""', z.string().default('ACTIVO').parse(''));
ver('catch', z.number().catch(0).parse('x'));

// 11. Reglas asíncronas
const Libre = z.string().refine(async (v) => v !== 'ana', 'Ya existe');
try {
  Libre.safeParse('ana');
} catch (error) {
  console.log('safeParse + refine async:', (error as Error).name, '-', (error as Error).message);
}
ver('safeParseAsync', (await Libre.safeParseAsync('ana')).error?.issues[0]?.message);

// 12. Idioma de los mensajes automáticos
z.config(z.locales.es());
ver('locale es', z.object({ a: z.string().min(3), b: z.email(), c: z.number() }).safeParse({ a: 'x', b: 'y', c: 'z' }).error?.issues.map((i) => i.message));
coerce "2500"                  2500
coerce "" con positive         "too_small"
coerce "" con min(0)           0
coerce "abc"                   "Invalid input: expected number, received NaN"
coerce null                    0
coerce.boolean "false"         true
stringbool "false"             false
undefined                      "Falta el nombre"
"  a "                         "Mínimo 2 letras"
"  Ana "                       "Ana"
optional con ""                false
union con ""                   null
union con 600100100            "600100100"
union con 12                   ["Nueve dígitos"]
refine                         [{"code":"custom","path":["fin"],"message":"El fin no puede ser anterior"}]
refine con un campo mal        ["inicio: Fecha no válida","fin: El fin no puede ser anterior"]
refine con tipo incorrecto     ["minimo: Número"]
superRefine                    ["Mínimo 8 caracteres","Al menos un número"]
flattenError                   {"formErrors":[],"fieldErrors":{"nombre":["Corto"],"edad":["Debe ser un número"]}}
treeifyError                   {"errors":[],"properties":{"nombre":{"errors":["Corto"]},"edad":{"errors":["Debe ser un número"]}}}
✖ Corto
  → at nombre
✖ Debe ser un número
  → at edad
discriminada correcta          true
discriminada con error         ["cif: CIF incompleto"]
discriminada sin tipo          ["tipo: invalid_union"]
object las quita               {"a":1}
strictObject las rechaza       "unrecognized_keys"
looseObject las deja           {"a":1,"b":2}
transform y pipe               12.5
transform y pipe, mal          ["No es un número"]
default con undefined          "ACTIVO"
default con ""                 ""
catch                          0
safeParse + refine async: Error - Encountered Promise during synchronous parse. Use .parseAsync() instead.
safeParseAsync                 "Ya existe"
locale es                      ["Demasiado pequeño: se esperaba que texto tuviera >=3 caracteres","Inválido dirección de correo electrónico","Entrada inválida: se esperaba número, recibido texto"]

Los mensajes

En Zod 4 casi todas las funciones aceptan el mensaje como cadena o como { error: '...' }. Dentro de ctx.addIssue, el texto va en la propiedad message. z.string('mensaje') define el mensaje cuando falta el dato o tiene otro tipo.

La traducción automática existe (z.config(z.locales.es())), pero ya ves cómo queda: “Inválido dirección de correo electrónico”. En una interfaz, escribe tus mensajes.

Formatos más usados

Esquema Valida
z.email() Dirección de correo
z.url(), z.uuid() URL, UUID
z.iso.date() Fecha AAAA-MM-DD, lo que da un input type="date"
z.iso.datetime(), z.iso.time() Fecha y hora, solo hora
z.string().regex(/.../, 'mensaje') Lo que quieras
z.enum(['A', 'B']) Una de esas opciones
z.literal('x') Exactamente ese valor
z.array(e).min(1) Listas
z.stringbool() Texto "true", "false", "1", "0"… convertido a booleano

Cuatro trampas de los formularios

1. z.coerce.number() convierte '' en 0. Lo muestra la salida: coerce "" con min(0) da 0. El usuario deja el campo vacío y tu base de datos recibe un cero sin que nadie se entere. Lo mismo pasa con null, y con booleanos todo es peor: z.coerce.boolean() convierte "false" en true, porque es una cadena no vacía. Para esto último usa z.stringbool().

Tienes dos salidas seguras. La más simple es dejar que el campo entregue un número y validar con z.number():

salario: z.number('Escribe un número').positive('Debe ser mayor que cero'),
// ...
<input type="number" {...register('salario', { valueAsNumber: true })} />

El NaN del campo vacío no pasa z.number(), y sale tu mensaje. La segunda es mantener el texto y comprobar primero que hay algo, como en el ejercicio 3.

2. .optional() no significa “campo vacío”. Un input vacío vale '', no undefined. Por eso optional con "" da false: la cadena vacía no cumple la expresión regular. La forma correcta acepta '' de manera explícita con una unión, y si quieres la convierte en null:

const Tel = z.union([z.literal(''), z.string().regex(/^\d{9}$/, 'Nueve dígitos')])
  .transform((v) => v || null);

3. .default() tampoco sirve para el vacío. Solo actúa con undefined: default con "" devuelve "". Si quieres un valor por defecto en el formulario, ponlo en defaultValues.

4. Un refine de objeto puede ejecutarse con datos incorrectos. Mira las dos líneas de la salida:

refine con un campo mal        ["inicio: Fecha no válida","fin: El fin no puede ser anterior"]
refine con tipo incorrecto     ["minimo: Número"]

Si un campo falla por formato (z.iso.date() sobre 'x'), el dato sigue teniendo el tipo correcto y Zod ejecuta igualmente el refine, que compara 'x' con la otra fecha y añade un error que confunde. Si falla por tipo, el refine del objeto no se ejecuta, porque no podría garantizar nada. Dos consecuencias:

  • Tu refine debe tolerar datos con mal formato.
  • Un refine de objeto puede no aparecer mientras otro campo tenga el tipo incorrecto, por ejemplo un z.number() sin rellenar. El error “las fechas están al revés” no saldrá hasta que el salario sea un número.

refine o superRefine

Quieres Usa
Una condición, un mensaje .refine(fn, 'mensaje')
Asignar el error a un campo concreto .refine(fn, { error: '...', path: ['campo'] }) en el objeto
Varios errores a la vez, o una posición dentro de un array .superRefine((valor, ctx) => ctx.addIssue(...))

Objetos: reutilizar y restringir

z.object quita las claves desconocidas, z.strictObject las rechaza y z.looseObject las conserva. Para formularios conviene z.object: si mandas al servidor lo que sale del esquema, nunca se cuela un campo que no esperabas.

Un esquema se puede derivar de otro con .extend({...}), .pick({...}), .omit({...}) y .partial(). Es muy útil para el alta y la edición:

const EsquemaEdicion = EsquemaEmpleado.partial().extend({ empId: z.number() });

9. Conectar Zod con zodResolver

Un resolver es una función que sustituye a las reglas de register: recibe los valores y devuelve valores o errores. @hookform/resolvers trae uno para cada biblioteca de validación. El de Zod reconoce por sí solo si el esquema es de Zod 3 o de Zod 4.

// src/AltaProducto.tsx
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

const EsquemaProducto = z.object({
  nombre: z.string().trim().min(3, 'Escribe al menos 3 letras'),
  // El usuario escribe "12,50": entra texto y sale un número
  precio: z
    .string()
    .trim()
    .min(1, 'El precio es obligatorio')
    .transform((texto) => Number(texto.replace(',', '.')))
    .pipe(z.number('Escribe un número').min(0, 'No puede ser negativo')),
  // valueAsNumber entrega un número, o NaN si el campo está vacío
  stock: z.number('Escribe un número').int('Debe ser entero').min(0, 'No puede ser negativo'),
  // Entra "oferta, nuevo" y sale ["oferta", "nuevo"]
  etiquetas: z.string().transform((texto) =>
    texto.split(',').map((t) => t.trim()).filter(Boolean),
  ),
});

type EntradaProducto = z.input<typeof EsquemaProducto>;
type SalidaProducto = z.output<typeof EsquemaProducto>;

export default function AltaProducto() {
  const [enviado, setEnviado] = useState<SalidaProducto | null>(null);
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<EntradaProducto, unknown, SalidaProducto>({
    resolver: zodResolver(EsquemaProducto),
    defaultValues: { nombre: '', precio: '', etiquetas: '' },
  });

  return (
    <form onSubmit={handleSubmit(setEnviado)} noValidate>
      <h2>Nuevo producto</h2>

      <div className="campo">
        <label htmlFor="nombre">Nombre</label>
        <input id="nombre" aria-invalid={errors.nombre ? true : undefined} {...register('nombre')} />
        {errors.nombre && <p className="error">{errors.nombre.message}</p>}
      </div>

      <div className="fila">
        <div className="campo">
          <label htmlFor="precio">Precio</label>
          <input id="precio" inputMode="decimal" aria-invalid={errors.precio ? true : undefined} {...register('precio')} />
          {errors.precio && <p className="error">{errors.precio.message}</p>}
        </div>
        <div className="campo">
          <label htmlFor="stock">Stock</label>
          <input id="stock" type="number" aria-invalid={errors.stock ? true : undefined} {...register('stock', { valueAsNumber: true })} />
          {errors.stock && <p className="error">{errors.stock.message}</p>}
        </div>
      </div>

      <div className="campo">
        <label htmlFor="etiquetas">Etiquetas (separadas por comas)</label>
        <input id="etiquetas" {...register('etiquetas')} />
      </div>

      <button type="submit">Guardar</button>
      {enviado && <pre>{JSON.stringify(enviado, null, 2)}</pre>}
    </form>
  );
}

Al escribir 12,50 en el precio y oferta, nuevo , en las etiquetas, handleSubmit entrega datos ya limpios y con otro tipo:

Formulario de producto con el precio escrito con coma y, debajo, el JSON con el precio como número y las etiquetas como array

Figura 8. El formulario tiene texto; onValid recibe un número y un array.

Los tres tipos de useForm

Esta es la parte que más confunde. useForm tiene tres parámetros de tipo:

useForm<TFieldValues, TContext, TTransformedValues>
Parámetro Significado Aquí
TFieldValues Lo que hay en los campos EntradaProducto
TContext Datos extra para el resolver, casi nunca los usas unknown
TTransformedValues Lo que recibe handleSubmit SalidaProducto

El formulario contiene el tipo de entrada, el esquema lo comprueba y transforma, y onValid recibe el tipo de salida

Figura 9. z.input describe el formulario y z.output lo que sale de él.

Si tu esquema no transforma nada, entrada y salida coinciden y basta con useForm<Cliente>. Si transforma, y escribes solo un tipo, TypeScript protesta o te engaña. Regla práctica: si hay transform en el esquema, escribe los tres parámetros.

Con coerce hay otro detalle: el tipo de entrada de z.coerce.number() es unknown, porque aceptaría cualquier cosa. Eso impide meterlo en un .pipe que venga de un string, y deja sin tipos el campo. TypeScript da este error:

Argument of type 'ZodCoercedNumber<unknown>' is not assignable to parameter of type '$ZodType<any, string, ...>'

La solución es declarar el tipo de entrada: z.coerce.number<string>().

Con un resolver, las reglas de register no cuentan

Comprobado: con register('a', { required: 'Obligatorio' }) y un resolver que acepta la cadena vacía, el formulario se envía. Si hay resolver, las reglas de validación de register se ignoran sin ningún aviso. Toda la validación debe estar en el esquema. valueAsNumber y setValueAs sí siguen funcionando, porque convierten el valor, no lo validan.

Opciones de zodResolver

La firma real, simplificada, es esta:

zodResolver(esquema, opcionesDeParse?, opcionesDelResolver?)
Argumento Opción Para qué
2.º error, reportInput, jitless Se pasan a Zod al validar
3.º mode: 'async' \| 'sync' Cómo ejecuta el esquema. Por defecto, asíncrono, necesario si hay reglas asíncronas
3.º raw: true handleSubmit recibe los valores originales, sin transformar

Varios errores en un campo

Por defecto, RHF se queda con el primer error de cada campo. Con criteriaMode: 'all' en useForm los conserva todos en errors.campo.types. Lo usa el ejercicio 2.


10. Errores de servidor

Tu validación nunca puede ser la última palabra. Solo el servidor sabe si un email ya existe, si queda stock o si tu sesión sigue viva. La API EMPRESA responde a esos casos así:

{
  "status": 422,
  "mensaje": "Hay datos no válidos",
  "detalles": { "email": "Ese email ya está en uso" }
}

setError mete un error en el formulario como si lo hubiera encontrado RHF. Hay dos destinos:

  • Un campo: setError('email', { message }). El mensaje sale junto al campo.
  • root: setError('root.servidor', { message }). Es para errores sin campo. El nombre tras root. lo eliges tú, y se lee en errors.root.servidor.

Un error de la API se reparte según su tipo: los 422 con campos conocidos van a setError de cada campo, y el resto a root.servidor

Figura 10. Cada error debe acabar donde el usuario pueda hacer algo con él.

Esta función lo hace para cualquier formulario. Comprueba que el campo exista, porque la API puede devolver un detalle que no corresponde a ninguno: el pedido devuelve lineas, por ejemplo. Un error en un campo inexistente no se vería nunca.

// src/erroresServidor.ts
import type { FieldValues, Path, UseFormGetValues, UseFormSetError } from 'react-hook-form';
import { ErrorApi } from './api.ts';

export function volcarErrores<T extends FieldValues>(
  error: unknown,
  setError: UseFormSetError<T>,
  getValues: UseFormGetValues<T>,
): void {
  if (error instanceof ErrorApi && error.status === 422) {
    const valores = getValues();
    const campos = Object.keys(error.detalles);
    const delFormulario = campos.filter((campo) => campo in valores);

    delFormulario.forEach((campo, i) => {
      setError(
        campo as Path<T>,
        { type: 'servidor', message: error.detalles[campo] },
        { shouldFocus: i === 0 }, // el foco, solo al primero
      );
    });

    // Un detalle que no corresponde a ningún campo no debe perderse
    const sueltos = campos.filter((campo) => !(campo in valores));
    if (sueltos.length > 0) {
      setError('root.servidor', {
        type: '422',
        message: sueltos.map((campo) => error.detalles[campo]).join('. '),
      });
    }
    return;
  }

  setError('root.servidor', {
    type: error instanceof ErrorApi ? String(error.status) : 'red',
    message: error instanceof ErrorApi
      ? error.message
      : 'No se ha podido conectar con el servidor. Inténtalo de nuevo.',
  });
}

Ahora el caso completo. El esquema queda en su propio fichero para reutilizarlo, y aquí el transform del teléfono convierte el vacío en null, que es lo que espera la base de datos:

// src/esquemaEmpleado.ts
import { z } from 'zod';

export const EsquemaEmpleado = z.object({
  nombre: z.string().trim().min(1, 'El nombre es obligatorio'),
  apellidos: z.string().trim().min(1, 'Los apellidos son obligatorios'),
  email: z.email('El email no es válido').toLowerCase(),
  // Opcional: en un campo de texto "vacío" significa '', no undefined
  telefono: z
    .union([z.literal(''), z.string().regex(/^\d{9}$/, 'Debe tener nueve dígitos')])
    .transform((valor) => valor || null),
  fechaAlta: z.iso.date('Elige una fecha'),
  oficio: z.string().trim().min(1, 'El oficio es obligatorio'),
  salario: z.number('Escribe un número').positive('Debe ser mayor que cero'),
  deptId: z.number('Elige un departamento'),
});

export type EntradaEmpleado = z.input<typeof EsquemaEmpleado>;
export type SalidaEmpleado = z.output<typeof EsquemaEmpleado>;
// src/AltaEmpleado.tsx
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { pedir } from './api.ts';
import { EsquemaEmpleado } from './esquemaEmpleado.ts';
import type { EntradaEmpleado, SalidaEmpleado } from './esquemaEmpleado.ts';
import { volcarErrores } from './erroresServidor.ts';

interface Departamento {
  deptId: number;
  nombre: string;
}

interface Pagina<T> {
  contenido: T[];
}

export default function AltaEmpleado() {
  const [departamentos, setDepartamentos] = useState<Departamento[]>([]);
  const [creado, setCreado] = useState<number | null>(null);
  const {
    register,
    handleSubmit,
    reset,
    setError,
    getValues,
    formState: { errors, isSubmitting },
  } = useForm<EntradaEmpleado, unknown, SalidaEmpleado>({
    resolver: zodResolver(EsquemaEmpleado),
    defaultValues: {
      nombre: '', apellidos: '', email: '', telefono: '', fechaAlta: '', oficio: '',
    },
  });

  useEffect(() => {
    let vigente = true;
    pedir<Pagina<Departamento>>('/departamentos').then((pagina) => {
      if (vigente) {
        setDepartamentos(pagina.contenido);
      }
    });
    return () => {
      vigente = false;
    };
  }, []);

  async function guardar(datos: SalidaEmpleado) {
    setCreado(null);
    try {
      const nuevo = await pedir<{ empId: number }>('/empleados', 'POST', datos);
      setCreado(nuevo.empId);
      reset();
    } catch (error) {
      volcarErrores(error, setError, getValues);
    }
  }

  return (
    <form onSubmit={handleSubmit(guardar)} noValidate>
      <h2>Alta de empleado</h2>

      {errors.root?.servidor && (
        <p className="aviso" role="alert">{errors.root.servidor.message}</p>
      )}

      <div className="fila">
        <div className="campo">
          <label htmlFor="nombre">Nombre</label>
          <input id="nombre" aria-invalid={errors.nombre ? true : undefined} {...register('nombre')} />
          {errors.nombre && <p className="error">{errors.nombre.message}</p>}
        </div>
        <div className="campo">
          <label htmlFor="apellidos">Apellidos</label>
          <input id="apellidos" aria-invalid={errors.apellidos ? true : undefined} {...register('apellidos')} />
          {errors.apellidos && <p className="error">{errors.apellidos.message}</p>}
        </div>
      </div>

      <div className="fila">
        <div className="campo">
          <label htmlFor="email">Email</label>
          <input id="email" type="email" aria-invalid={errors.email ? true : undefined} {...register('email')} />
          {errors.email && <p className="error">{errors.email.message}</p>}
        </div>
        <div className="campo">
          <label htmlFor="telefono">Teléfono (opcional)</label>
          <input id="telefono" aria-invalid={errors.telefono ? true : undefined} {...register('telefono')} />
          {errors.telefono && <p className="error">{errors.telefono.message}</p>}
        </div>
      </div>

      <div className="fila">
        <div className="campo">
          <label htmlFor="fechaAlta">Fecha de alta</label>
          <input id="fechaAlta" type="date" aria-invalid={errors.fechaAlta ? true : undefined} {...register('fechaAlta')} />
          {errors.fechaAlta && <p className="error">{errors.fechaAlta.message}</p>}
        </div>
        <div className="campo">
          <label htmlFor="oficio">Oficio</label>
          <input id="oficio" aria-invalid={errors.oficio ? true : undefined} {...register('oficio')} />
          {errors.oficio && <p className="error">{errors.oficio.message}</p>}
        </div>
      </div>

      <div className="fila">
        <div className="campo">
          <label htmlFor="salario">Salario</label>
          <input id="salario" type="number" aria-invalid={errors.salario ? true : undefined} {...register('salario', { valueAsNumber: true })} />
          {errors.salario && <p className="error">{errors.salario.message}</p>}
        </div>
        <div className="campo">
          <label htmlFor="deptId">Departamento</label>
          <select id="deptId" aria-invalid={errors.deptId ? true : undefined} {...register('deptId', { valueAsNumber: true })}>
            <option value="">Elige...</option>
            {departamentos.map((d) => (
              <option key={d.deptId} value={d.deptId}>{d.nombre}</option>
            ))}
          </select>
          {errors.deptId && <p className="error">{errors.deptId.message}</p>}
        </div>
      </div>

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Guardando...' : 'Dar de alta'}
      </button>
      {creado !== null && (
        <p className="exito" role="status">Empleado {creado} creado</p>
      )}
    </form>
  );
}

Con el email de una empleada que ya existe, Zod da el visto bueno, la API responde 422 y el error llega al campo, con el foco en él:

Alta de empleado con todos los campos rellenos y, bajo el email, el mensaje Ese email ya está en uso

Figura 11. Un 422 convertido en error de campo. El foco ya está en el email.

Si se corta la conexión, el error va arriba:

Alta de empleado con un aviso rojo encima de los campos: No se ha podido conectar con el servidor

Figura 12. Un error de red en root.servidor.

Comprobado en estas pruebas:

  • Un error de campo puesto con setError se borra en cuanto el usuario teclea en ese campo, porque tras el primer envío se revalida con cada cambio y el esquema no sabe nada de él.
  • Un error en root se borra solo al enviar de nuevo, y el reintento funciona sin hacer nada más.
  • Un setError no impide reenviar: el botón sigue activo.
  • Si guardar lanza una excepción y no la capturas, isSubmitting vuelve a false, pero el error sale como rechazo no capturado. Por eso el try/catch está dentro.

⚠️ Importante: Valida en los dos lados. La validación del cliente es para que el usuario no espere; la del servidor es la que protege los datos, porque cualquiera puede llamar a la API sin pasar por tu formulario.


11. Controller y useController

register necesita un elemento del DOM con value y eventos normales. Muchos componentes no son así: un selector de fechas de una biblioteca, un editor de texto o un grupo de estrellas hecho por ti. No tienen <input> al que dar un ref, y su valor no llega en un event.target.value.

Controller es el adaptador. Le da al componente lo mismo que register, pero en forma de valor controlado:

field Tu componente lo usa para
field.value Enseñar el valor
field.onChange(valor) Comunicar un valor nuevo, ya sea un evento o el valor directamente
field.onBlur() Marcar el campo como tocado; sin esto, onTouched y onBlur no funcionan
field.ref Permitir el foco si hay error
field.name, field.disabled Lo demás

fieldState aporta invalid, error, isTouched e isDirty de ese campo. useController es la versión en forma de hook: mismos datos, pero sin una prop render. Usa Controller para adaptar en el sitio un componente ajeno, y useController cuando escribes tu propio componente de formulario.

// src/ValorarProducto.tsx
import { useState } from 'react';
import type { Ref } from 'react';
import { Controller, useController, useForm } from 'react-hook-form';
import type { Control } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

// ---- Un componente que no sabe nada de formularios ----
interface PropsEstrellas {
  valor: number;
  alCambiar: (valor: number) => void;
  alSalir: () => void;
  invalido: boolean;
  ref?: Ref<HTMLButtonElement>;
}

function Estrellas({ valor, alCambiar, alSalir, invalido, ref }: PropsEstrellas) {
  return (
    <div
      role="group"
      aria-label="Puntuación"
      onBlur={alSalir}
      style={{ outline: invalido ? '2px solid #c62828' : 'none', borderRadius: 6, width: 'fit-content' }}
    >
      {[1, 2, 3, 4, 5].map((n) => (
        <button
          key={n}
          type="button"
          ref={n === 1 ? ref : undefined} // el primero recibe el foco si hay error
          aria-label={`${n} de 5`}
          aria-pressed={n === valor}
          onClick={() => alCambiar(n)}
          style={{ border: 'none', background: 'none', fontSize: '1.6rem', color: n <= valor ? '#ff7043' : '#c5cae9' }}
        >
          ★
        </button>
      ))}
    </div>
  );
}

// ---- Un campo que guarda un número y enseña un importe ----
const moneda = new Intl.NumberFormat('es-ES', { style: 'currency', currency: 'EUR' });

function CampoImporte({ control }: { control: Control<EntradaValoracion> }) {
  const { field, fieldState } = useController({ name: 'importe', control });
  const [texto, setTexto] = useState<string | null>(null); // null: no se está editando

  return (
    <div className="campo">
      <label htmlFor="importe">Importe de la compra</label>
      <input
        id="importe"
        ref={field.ref}
        name={field.name}
        inputMode="decimal"
        aria-invalid={fieldState.invalid ? true : undefined}
        value={texto ?? moneda.format(field.value)}
        onFocus={() => setTexto(String(field.value).replace('.', ','))}
        onChange={(e) => {
          setTexto(e.target.value);
          field.onChange(Number(e.target.value.replace(',', '.')));
        }}
        onBlur={() => {
          setTexto(null);
          field.onBlur();
        }}
      />
      {fieldState.error && <p className="error">{fieldState.error.message}</p>}
    </div>
  );
}

// ---- El formulario ----
const EsquemaValoracion = z.object({
  puntuacion: z.number().min(1, 'Elige una puntuación'),
  importe: z.number('Escribe un importe').positive('Debe ser mayor que cero'),
  comentario: z.string().max(80, 'Máximo 80 caracteres'),
});

type EntradaValoracion = z.input<typeof EsquemaValoracion>;

export default function ValorarProducto() {
  const [enviado, setEnviado] = useState<EntradaValoracion | null>(null);
  const {
    register,
    control,
    handleSubmit,
    formState: { errors },
  } = useForm<EntradaValoracion>({
    resolver: zodResolver(EsquemaValoracion),
    defaultValues: { puntuacion: 0, importe: 0, comentario: '' },
  });

  return (
    <form onSubmit={handleSubmit(setEnviado)} noValidate>
      <h2>Valorar un producto</h2>

      <div className="campo">
        <span>Puntuación</span>
        <Controller
          name="puntuacion"
          control={control}
          render={({ field, fieldState }) => (
            <Estrellas
              ref={field.ref}
              valor={field.value}
              alCambiar={field.onChange}
              alSalir={field.onBlur}
              invalido={fieldState.invalid}
            />
          )}
        />
        {errors.puntuacion && <p className="error">{errors.puntuacion.message}</p>}
      </div>

      <CampoImporte control={control} />

      <div className="campo">
        <label htmlFor="comentario">Comentario</label>
        <textarea id="comentario" rows={2} {...register('comentario')} />
        {errors.comentario && <p className="error">{errors.comentario.message}</p>}
      </div>

      <button type="submit">Enviar</button>
      {enviado && <pre>{JSON.stringify(enviado)}</pre>}
    </form>
  );
}

Formulario con cuatro estrellas marcadas, un importe formateado en euros y el JSON con la puntuación y el importe como número

Figura 13. Las estrellas y el importe son componentes propios, integrados en el mismo formulario y el mismo esquema.

Tres cosas para fijarse:

  • Estrellas no conoce RHF. Recibe valor y alCambiar, y podría usarse fuera de un formulario. Controller es lo único que los une.
  • CampoImporte separa lo que se guarda de lo que se enseña. El formulario guarda el número 1234.5, y el campo enseña 1234,50 €. Mientras se edita, texto conserva lo que estás escribiendo, porque si lo formateases en cada tecla el cursor saltaría. Por cierto, Intl.NumberFormat en es-ES no pone separador de miles hasta los cinco dígitos; es la norma, no un fallo.
  • defaultValues es obligatorio aquí. Sin él, field.value empieza en undefined y después pasa a tener valor. Con un <input {...field} /> React avisa: A component is changing an uncontrolled input to be controlled.

Un campo con Controller renderiza su parte en cada tecla, como cualquier campo controlado. Es lo esperable, y solo afecta a ese campo.

💡 Consejo: npm run lint da aquí un aviso react(refs), Cannot access refs during render, en las líneas que leen field.name o field.value tras useController. Es un falso positivo de oxlint: como field trae una propiedad ref, lo toma por una referencia. Esos valores son normales y se pueden leer durante el render.

Pasar field.ref a tu componente 🆕 19.0

🆕 Desde React 19.0. ref es una prop normal; antes no llegaba a los componentes función. Si usas una versión anterior: envuelve Estrellas con forwardRef<HTMLButtonElement, PropsEstrellas>((props, ref) => ...) y quita ref de la interfaz de props.

Gracias a field.ref, cuando falta la puntuación el foco va a la primera estrella, como comprobó la prueba al enviar el formulario vacío. Sin ese ref, el error aparecería pero el foco no llegaría a ninguna parte.


12. Formularios por partes

Un formulario grande se parte en componentes. Pasar register y errors como props a cada uno es incómodo, y aquí está la solución en dos pasos.

Un campo reutilizable 🆕 19.0

🆕 Desde React 19.0. ref llega como una prop más y {...resto} la entrega al <input>. Si usas una versión anterior: envuelve Entrada con forwardRef<HTMLInputElement, PropsEntrada> y añade ref={ref} al <input>, o register no recibirá el nodo y el campo fallará sin avisar.

En AltaEmpleado repetimos muchas veces el mismo bloque de etiqueta, campo y error. Este componente lo encapsula, con la accesibilidad incluida: useId del módulo 08 y aria-describedby para que el lector de pantalla lea el error al llegar al campo.

// src/Entrada.tsx
import { useId } from 'react';
import type { ComponentProps } from 'react';

interface PropsEntrada extends ComponentProps<'input'> {
  etiqueta: string;
  error?: string;
}

// ref llega como una prop más y sale hacia <input> dentro de ...resto
export function Entrada({ etiqueta, error, ...resto }: PropsEntrada) {
  const id = useId();
  const idError = `${id}-error`;

  return (
    <div className="campo">
      <label htmlFor={id}>{etiqueta}</label>
      <input
        id={id}
        aria-invalid={error ? true : undefined}
        aria-describedby={error ? idError : undefined}
        {...resto}
      />
      {error && <p id={idError} className="error">{error}</p>}
    </div>
  );
}

Se usa así: <Entrada etiqueta="Email" error={errors.email?.message} {...register('email')} />.

⚠️ Importante: El ref no es un detalle. Se ha probado con un componente que recibe name, onChange y onBlur pero no pasa ref al <input>, y el resultado es traicionero: no hay ningún error en consola, el valor escrito se ignora, y el formulario envía {"dato":"inicial"} o dice que el campo obligatorio está vacío aunque tenga texto.

FormProvider y useFormContext

FormProvider es un Context con todos los métodos de useForm. Los componentes de dentro los leen con useFormContext, a cualquier profundidad.

// src/FichaCliente.tsx
import { useState } from 'react';
import { ErrorMessage, FormProvider, useForm, useFormContext, useFormState } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Entrada } from './Entrada.tsx';

const EsquemaCliente = z.object({
  nombre: z.string().trim().min(2, 'Escribe el nombre'),
  nif: z.string().regex(/^[A-Z]\d{8}$/, 'Una letra y ocho dígitos'),
  contacto: z.object({
    email: z.email('El email no es válido'),
    telefono: z.string().regex(/^\d{9}$/, 'Debe tener nueve dígitos'),
  }),
});

type Cliente = z.infer<typeof EsquemaCliente>;

// Cada bloque es un componente; ninguno recibe register ni errors por props
function BloqueIdentidad() {
  const { register, formState: { errors } } = useFormContext<Cliente>();
  return (
    <fieldset>
      <legend>Identificación</legend>
      <Entrada etiqueta="Razón social" error={errors.nombre?.message} {...register('nombre')} />
      <Entrada etiqueta="NIF" error={errors.nif?.message} {...register('nif')} />
    </fieldset>
  );
}

function BloqueContacto() {
  const { register } = useFormContext<Cliente>();
  return (
    <fieldset>
      <legend>Contacto</legend>
      <div className="campo">
        <label htmlFor="email">Email</label>
        <input id="email" {...register('contacto.email')} />
        <ErrorMessage<Cliente> name="contacto.email" as="p" />
      </div>
      <div className="campo">
        <label htmlFor="telefono">Teléfono</label>
        <input id="telefono" {...register('contacto.telefono')} />
        <ErrorMessage<Cliente>
          name="contacto.telefono"
          render={({ message }) => <p className="error">{message}</p>}
        />
      </div>
    </fieldset>
  );
}

// Se suscribe solo a lo que necesita
function BotonEnviar() {
  const { isSubmitting, submitCount } = useFormState<Cliente>();
  return (
    <button type="submit" disabled={isSubmitting}>
      Guardar {submitCount > 0 ? `(intento ${submitCount + 1})` : ''}
    </button>
  );
}

export default function FichaCliente() {
  const [enviado, setEnviado] = useState<Cliente | null>(null);
  const metodos = useForm<Cliente>({
    resolver: zodResolver(EsquemaCliente),
    defaultValues: { nombre: '', nif: '', contacto: { email: '', telefono: '' } },
  });

  return (
    <FormProvider {...metodos}>
      <form onSubmit={metodos.handleSubmit(setEnviado)} noValidate>
        <h2>Ficha de cliente</h2>
        <BloqueIdentidad />
        <BloqueContacto />
        <BotonEnviar />
        {enviado && <pre>{JSON.stringify(enviado, null, 2)}</pre>}
      </form>
    </FormProvider>
  );
}

Ficha de cliente con dos bloques, cuatro errores y el botón que indica intento 2

Figura 14. Cada bloque es un componente. El mensaje del email usa as="p" y el del teléfono usa render.

Aquí aparecen tres piezas nuevas:

  • useFormContext<Cliente>() devuelve lo mismo que useForm. El tipo hay que repetirlo, porque el contexto no puede deducirlo.
  • useFormState<Cliente>() se suscribe a formState desde otro componente. BotonEnviar es un buen ejemplo: si isSubmitting y submitCount se leyeran en el padre, sería el padre el que se renderiza. También admite name para escuchar solo un campo.
  • ErrorMessage es un componente que busca solo el error del campo. Lo exporta el propio react-hook-form. Con as="p" solo eliges la etiqueta, y por eso en la figura ese mensaje no tiene la clase error. Para dar estilo, usa render.

Si tus nombres de campo son anidados, como contacto.email, las rutas funcionan y errors.contacto?.email existe. TypeScript comprueba además cada ruta: register('contacto.emial') no compila.

💡 Consejo: No necesitas FormProvider para todo. Para un componente que recibe uno o dos campos, pasar control como prop, como hace CampoImporte, es más explícito. Resérvalo para formularios con bloques anidados.


13. Listas de campos con useFieldArray

Un pedido tiene un número variable de líneas. Podrías gestionarlas con un estado propio, pero tendrías que mantener a mano los nombres, los errores y qué línea es cuál. useFieldArray lo hace por ti:

// src/NuevoPedido.tsx
import { useEffect, useState } from 'react';
import { useFieldArray, useForm, useWatch } from 'react-hook-form';
import type { Control } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { pedir } from './api.ts';
import { volcarErrores } from './erroresServidor.ts';

interface Cliente {
  clienteId: number;
  nombre: string;
}

interface Producto {
  productoId: number;
  nombre: string;
  precio: number;
}

interface Pagina<T> {
  contenido: T[];
}

const EsquemaLinea = z.object({
  productoId: z.number('Elige un producto'),
  cantidad: z.number('Escribe la cantidad').int('Debe ser entera').min(1, 'Mínimo 1'),
});

const EsquemaPedido = z.object({
  clienteId: z.number('Elige un cliente'),
  lineas: z
    .array(EsquemaLinea)
    .min(1, 'Añade al menos una línea')
    .superRefine((lineas, ctx) => {
      const vistos = new Set<number>();
      lineas.forEach((linea, i) => {
        if (vistos.has(linea.productoId)) {
          ctx.addIssue({ code: 'custom', message: 'Producto repetido', path: [i, 'productoId'] });
        }
        vistos.add(linea.productoId);
      });
    }),
});

type Pedido = z.infer<typeof EsquemaPedido>;

// Se suscribe a las líneas: el total se recalcula sin pintar el formulario entero
function Total({ control, productos }: { control: Control<Pedido>; productos: Producto[] }) {
  const lineas = useWatch({ control, name: 'lineas' });
  const total = lineas.reduce((suma, linea) => {
    const producto = productos.find((p) => p.productoId === linea.productoId);
    return suma + (producto?.precio ?? 0) * (linea.cantidad || 0);
  }, 0);

  return <p>Total: <strong data-prueba="total">{total.toFixed(2)} €</strong></p>;
}

export default function NuevoPedido() {
  const [clientes, setClientes] = useState<Cliente[]>([]);
  const [productos, setProductos] = useState<Producto[]>([]);
  const [creado, setCreado] = useState<number | null>(null);
  const {
    register,
    control,
    handleSubmit,
    reset,
    setError,
    getValues,
    formState: { errors, isSubmitting },
  } = useForm<Pedido>({
    resolver: zodResolver(EsquemaPedido),
    defaultValues: { lineas: [{ cantidad: 1 }] },
  });
  const { fields, append, remove, move } = useFieldArray({ control, name: 'lineas' });

  useEffect(() => {
    let vigente = true;
    Promise.all([
      pedir<Pagina<Cliente>>('/clientes?tamano=100'),
      pedir<Pagina<Producto>>('/productos?tamano=100&disponibles=true'),
    ]).then(([c, p]) => {
      if (vigente) {
        setClientes(c.contenido);
        setProductos(p.contenido);
      }
    });
    return () => {
      vigente = false;
    };
  }, []);

  async function guardar(datos: Pedido) {
    setCreado(null);
    try {
      const nuevo = await pedir<{ pedidoId: number }>('/pedidos', 'POST', datos);
      setCreado(nuevo.pedidoId);
      reset();
    } catch (error) {
      volcarErrores(error, setError, getValues);
    }
  }

  return (
    <form onSubmit={handleSubmit(guardar)} noValidate style={{ maxWidth: 680 }}>
      <h2>Nuevo pedido</h2>

      {errors.root?.servidor && (
        <p className="aviso" role="alert">{errors.root.servidor.message}</p>
      )}

      <div className="campo">
        <label htmlFor="cliente">Cliente</label>
        <select
          id="cliente"
          aria-invalid={errors.clienteId ? true : undefined}
          {...register('clienteId', { valueAsNumber: true })}
        >
          <option value="">Elige...</option>
          {clientes.map((c) => (
            <option key={c.clienteId} value={c.clienteId}>{c.nombre}</option>
          ))}
        </select>
        {errors.clienteId && <p className="error">{errors.clienteId.message}</p>}
      </div>

      {fields.map((field, i) => (
        // key={field.id}: un identificador estable que genera useFieldArray
        <div className="fila" key={field.id} data-prueba="linea">
          <div className="campo" style={{ flex: 3 }}>
            <label htmlFor={`producto-${i}`}>Producto {i + 1}</label>
            <select
              id={`producto-${i}`}
              aria-invalid={errors.lineas?.[i]?.productoId ? true : undefined}
              {...register(`lineas.${i}.productoId`, { valueAsNumber: true })}
            >
              <option value="">Elige...</option>
              {productos.map((p) => (
                <option key={p.productoId} value={p.productoId}>{p.nombre}</option>
              ))}
            </select>
            {errors.lineas?.[i]?.productoId && (
              <p className="error">{errors.lineas[i].productoId.message}</p>
            )}
          </div>
          <div className="campo">
            <label htmlFor={`cantidad-${i}`}>Cantidad</label>
            <input
              id={`cantidad-${i}`}
              type="number"
              aria-invalid={errors.lineas?.[i]?.cantidad ? true : undefined}
              {...register(`lineas.${i}.cantidad`, { valueAsNumber: true })}
            />
            {errors.lineas?.[i]?.cantidad && (
              <p className="error">{errors.lineas[i].cantidad.message}</p>
            )}
          </div>
          <button type="button" aria-label={`Subir línea ${i + 1}`} disabled={i === 0} onClick={() => move(i, i - 1)}>↑</button>
          <button type="button" aria-label={`Quitar línea ${i + 1}`} onClick={() => remove(i)}>✕</button>
        </div>
      ))}

      {errors.lineas?.root && <p className="error" role="alert">{errors.lineas.root.message}</p>}

      <div className="fila">
        <button type="button" onClick={() => append({ productoId: Number.NaN, cantidad: 1 })}>
          Añadir línea
        </button>
      </div>

      <Total control={control} productos={productos} />

      <button type="submit" disabled={isSubmitting}>Crear pedido</button>
      {creado !== null && <p className="exito" role="status">Pedido {creado} creado</p>}
    </form>
  );
}

Pedido con un cliente, dos líneas de producto con botones para subir y quitar, el total y un aviso rojo de falta de stock

Figura 15. El aviso de stock viene del servidor, un 409 sin campo asociado.

Los métodos

Método Efecto
append(valor) Añade al final y pone el foco en la fila nueva
prepend(valor) Añade al principio
insert(i, valor) Inserta en una posición
remove(i) Quita una o varias; sin argumentos, todas
move(de, a) Mueve una fila
swap(a, b) Intercambia dos filas
update(i, valor) Sustituye una fila
replace(lista) Sustituye la lista entera

append y los demás admiten { shouldFocus: false } para no mover el foco.

Por qué key={field.id}

fields no es tu array: es una copia en la que cada elemento lleva un id generado. Ese id es la key correcta, por lo que aprendiste en el módulo 06. El nombre del campo, en cambio, sí usa la posición.

Al quitar la primera línea, las siguientes conservan su key pero cambian de nombre: la línea 1 pasa a ser lineas.0

Figura 16. La key sigue a la fila. El nombre sigue a la posición.

⚠️ Importante: Si tus objetos ya tienen una propiedad id, fields[i].id no es la tuya. En la prueba, con { id: 7 }, fields[0].id valía e012d077-90d5-.... Tus datos no se pierden, porque handleSubmit entrega { id: 7 }, pero si quieres leerlo en la pantalla usa otro nombre para la clave: useFieldArray({ control, name, keyName: 'clave' }).

Errores de la lista

Hay tres niveles, y cada uno se lee en un sitio distinto:

Error Dónde está Ejemplo
De un campo de una fila errors.lineas?.[i]?.cantidad “Mínimo 1”
De la lista entera errors.lineas?.root “Añade al menos una línea”
De una fila, puesto por superRefine con path errors.lineas?.[i]?.productoId “Producto repetido”

El segundo caso es la trampa: el error de la lista está en .root, no en errors.lineas.message, y solo si el campo es un useFieldArray.

El total se calcula en un componente aparte, Total, con useWatch. Si estuviera en NuevoPedido, cada tecla en una cantidad renderizaría todas las filas. Observa también append({ productoId: Number.NaN, cantidad: 1 }): el select no tiene valor aún y NaN lo deja en Elige…, y z.number() lo rechazará con su mensaje si nadie elige.


14. Formularios dinámicos

Campos que dependen de otro campo

Un cliente puede ser particular o empresa, y cada uno necesita datos distintos. Lo que describe bien ese “o” es una unión discriminada, que ya conoces de TypeScript (módulo 13). Zod tiene la suya:

// src/ClienteDinamico.tsx
import { useState } from 'react';
import { useForm, useWatch } from 'react-hook-form';
import type { FieldErrors } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Entrada } from './Entrada.tsx';

const Comun = z.object({
  email: z.email('El email no es válido'),
});

const EsquemaCliente = z.discriminatedUnion('tipo', [
  Comun.extend({
    tipo: z.literal('particular'),
    nombre: z.string().trim().min(2, 'Escribe tu nombre'),
    dni: z.string().regex(/^\d{8}[A-Z]$/, 'Ocho dígitos y una letra'),
  }),
  Comun.extend({
    tipo: z.literal('empresa'),
    razonSocial: z.string().trim().min(2, 'Escribe la razón social'),
    cif: z.string().regex(/^[A-Z]\d{8}$/, 'Una letra y ocho dígitos'),
    empleados: z.number('Escribe un número').int().min(1, 'Mínimo 1'),
  }),
]);

type Cliente = z.infer<typeof EsquemaCliente>;

// Dentro de cada rama, TypeScript solo conoce los campos de esa rama
type ErroresDe<T extends Cliente['tipo']> = FieldErrors<Extract<Cliente, { tipo: T }>>;

export default function ClienteDinamico() {
  const [enviado, setEnviado] = useState<Cliente | null>(null);
  const {
    register,
    control,
    handleSubmit,
    formState: { errors },
  } = useForm<Cliente>({
    resolver: zodResolver(EsquemaCliente),
    shouldUnregister: true, // lo que deja de pintarse deja de existir
    defaultValues: { tipo: 'particular', email: '' },
  });
  const tipo = useWatch({ control, name: 'tipo' });

  const erroresParticular = errors as ErroresDe<'particular'>;
  const erroresEmpresa = errors as ErroresDe<'empresa'>;

  return (
    <form onSubmit={handleSubmit(setEnviado)} noValidate>
      <h2>Alta de cliente</h2>

      <fieldset>
        <legend>Tipo de cliente</legend>
        <label><input type="radio" value="particular" {...register('tipo')} /> Particular</label>{' '}
        <label><input type="radio" value="empresa" {...register('tipo')} /> Empresa</label>
      </fieldset>

      {tipo === 'particular' && (
        <>
          <Entrada etiqueta="Nombre" error={erroresParticular.nombre?.message} {...register('nombre')} />
          <Entrada etiqueta="DNI" error={erroresParticular.dni?.message} {...register('dni')} />
        </>
      )}

      {tipo === 'empresa' && (
        <>
          <Entrada etiqueta="Razón social" error={erroresEmpresa.razonSocial?.message} {...register('razonSocial')} />
          <Entrada etiqueta="CIF" error={erroresEmpresa.cif?.message} {...register('cif')} />
          <Entrada
            etiqueta="Número de empleados"
            type="number"
            error={erroresEmpresa.empleados?.message}
            {...register('empleados', { valueAsNumber: true })}
          />
        </>
      )}

      <Entrada etiqueta="Email" type="email" error={errors.email?.message} {...register('email')} />

      <button type="submit">Guardar</button>
      {enviado && <pre>{JSON.stringify(enviado, null, 2)}</pre>}
    </form>
  );
}

Formulario de cliente con la opción Empresa marcada y sus campos propios con errores

Figura 17. Al elegir empresa aparecen sus campos, y la validación es la de esa rama.

Las piezas:

  • useWatch({ control, name: 'tipo' }) provoca el render que decide qué campos se pintan.
  • z.discriminatedUnion elige la rama por tipo y da errores solo de esa rama. Comprobado: con tipo: 'empresa' no sale ningún error de dni.
  • Comun.extend evita repetir los campos que comparten las dos ramas.
  • shouldUnregister: true hace que un campo desmontado pierda su valor. En la prueba, tras escribir una razón social y cambiar a particular, los datos enviados no llevan razonSocial, y al volver a empresa el campo está vacío.

📘 Concepto: shouldUnregister es una decisión de negocio. Con false, el valor por defecto, un campo oculto conserva lo escrito: es lo que quieres en un asistente por pasos. Con true, lo oculto deja de existir: es lo que quieres cuando el formulario cambia de forma.

Los errores necesitan una conversión porque FieldErrors de una unión no conoce los campos de cada rama. Por eso están erroresParticular y erroresEmpresa. Es un as razonable, ya que el if de tipo garantiza la rama.

Un asistente por pasos

Un asistente es un único useForm con varias pantallas, y es mucho más sencillo que el del módulo 08, donde gestionabas tú cada estado:

// src/AsistenteAlta.tsx
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import type { FieldPath } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Entrada } from './Entrada.tsx';

const EsquemaAlta = z
  .object({
    nombre: z.string().trim().min(2, 'Escribe el nombre'),
    email: z.email('El email no es válido'),
    oficio: z.string().trim().min(2, 'Escribe el oficio'),
    salario: z.number('Escribe un número').positive('Debe ser mayor que cero'),
    fechaAlta: z.iso.date('Elige una fecha'),
    fechaFin: z.union([z.literal(''), z.iso.date()]),
  })
  .refine((d) => d.fechaFin === '' || d.fechaFin >= d.fechaAlta, {
    error: 'No puede ser anterior al alta',
    path: ['fechaFin'],
  });

type Alta = z.infer<typeof EsquemaAlta>;

// Qué campos valida cada paso
const PASOS: { titulo: string; campos: FieldPath<Alta>[] }[] = [
  { titulo: 'Datos personales', campos: ['nombre', 'email'] },
  { titulo: 'Puesto', campos: ['oficio', 'salario'] },
  { titulo: 'Contrato', campos: ['fechaAlta', 'fechaFin'] },
];

export default function AsistenteAlta() {
  const [paso, setPaso] = useState(0);
  const [enviado, setEnviado] = useState<Alta | null>(null);
  const {
    register,
    trigger,
    handleSubmit,
    formState: { errors },
  } = useForm<Alta>({
    resolver: zodResolver(EsquemaAlta),
    defaultValues: { nombre: '', email: '', oficio: '', fechaAlta: '', fechaFin: '' },
  });

  async function siguiente() {
    // Solo sigue si los campos de este paso son válidos
    const correcto = await trigger(PASOS[paso].campos);
    if (correcto) {
      setPaso(paso + 1);
    }
  }

  const ultimo = paso === PASOS.length - 1;

  return (
    <form onSubmit={handleSubmit(setEnviado)} noValidate>
      <h2>Paso {paso + 1} de {PASOS.length}: {PASOS[paso].titulo}</h2>
      <progress value={paso + 1} max={PASOS.length} style={{ width: '100%' }} />

      {paso === 0 && (
        <>
          <Entrada etiqueta="Nombre" error={errors.nombre?.message} {...register('nombre')} />
          <Entrada etiqueta="Email" type="email" error={errors.email?.message} {...register('email')} />
        </>
      )}
      {paso === 1 && (
        <>
          <Entrada etiqueta="Oficio" error={errors.oficio?.message} {...register('oficio')} />
          <Entrada
            etiqueta="Salario"
            type="number"
            error={errors.salario?.message}
            {...register('salario', { valueAsNumber: true })}
          />
        </>
      )}
      {paso === 2 && (
        <>
          <Entrada etiqueta="Fecha de alta" type="date" error={errors.fechaAlta?.message} {...register('fechaAlta')} />
          <Entrada etiqueta="Fecha de fin (opcional)" type="date" error={errors.fechaFin?.message} {...register('fechaFin')} />
        </>
      )}

      <div className="fila">
        <button type="button" disabled={paso === 0} onClick={() => setPaso(paso - 1)}>Atrás</button>
        {/* key: evita que React reutilice el botón al cambiar de tipo (módulo 08) */}
        {ultimo
          ? <button key="enviar" type="submit">Terminar</button>
          : <button key="seguir" type="button" onClick={siguiente}>Siguiente</button>}
      </div>
      {enviado && <pre>{JSON.stringify(enviado, null, 2)}</pre>}
    </form>
  );
}

Asistente en el paso 1 de 3 con una barra de progreso y dos errores

Figura 18. trigger valida solo los campos del paso actual.

  • trigger(campos) valida un grupo de campos. Devuelve una promesa con true si todos están bien.
  • Los campos de otros pasos se desmontan, pero conservan su valor porque shouldUnregister es false. La prueba lo confirma: al volver atrás, Marta sigue ahí, y el resultado final incluye los seis campos.
  • El refine de las fechas se ejecuta en el último paso, con el esquema entero.
  • La key de los botones evita un fallo clásico del módulo 08: si React reutiliza el mismo <button> y cambia su type a submit mientras se procesa el clic, el formulario se envía solo.

💡 Consejo: Si dejas saltar libremente entre pasos, un campo de un paso anterior puede tener error en el envío final, y el usuario no lo vería. handleSubmit admite un segundo argumento, handleSubmit(alAcertar, alFallar): en alFallar recibes los errores y puedes volver al paso que corresponde.


15. Validación asíncrona

“¿Está libre este email?” requiere preguntar al servidor. Zod lo permite con un refine asíncrono, y zodResolver lo soporta porque por defecto valida en modo asíncrono:

// src/EmailDisponible.tsx
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { pedir } from './api.ts';

async function emailLibre(email: string): Promise<boolean> {
  const pagina = await pedir<{ contenido: { email: string }[] }>(
    `/empleados?q=${encodeURIComponent(email)}`,
  );
  return !pagina.contenido.some((e) => e.email === email);
}

const EsquemaEmail = z.object({
  // abort: si el formato falla, no sigas con las comprobaciones siguientes
  email: z
    .email({ error: 'El email no es válido', abort: true })
    .refine(emailLibre, 'Ese email ya está en uso'),
});

type DatosEmail = z.infer<typeof EsquemaEmail>;

export default function EmailDisponible() {
  const [libre, setLibre] = useState(false);
  const {
    register,
    handleSubmit,
    formState: { errors, isValidating },
  } = useForm<DatosEmail>({
    mode: 'onBlur',
    resolver: zodResolver(EsquemaEmail),
    defaultValues: { email: '' },
  });

  return (
    <form onSubmit={handleSubmit(() => setLibre(true))} noValidate>
      <h2>Comprobar email</h2>
      <div className="campo">
        <label htmlFor="email">Email</label>
        <input id="email" type="email" {...register('email')} />
        {isValidating && <p role="status">Comprobando...</p>}
        {errors.email && <p className="error">{errors.email.message}</p>}
      </div>
      <button type="submit">Reservar</button>
      {libre && <p className="exito">Disponible</p>}
    </form>
  );
}

Lo importante aquí es cuántas peticiones lanzas. Estas son las que produce escribir marta.ruiz@empresa.es, de 21 caracteres:

mode Formato Peticiones
onChange z.email('...').refine(...) 21, una por tecla
onChange z.email({ error, abort: true }).refine(...) 1
onBlur z.email({ error, abort: true }).refine(...) 1

Sin abort: true, el refine se ejecuta aunque el formato sea incorrecto, y ya sabes por qué: un fallo de formato no detiene las comprobaciones siguientes. El abort las detiene. El caso onChange da 1 solo porque la dirección se vuelve válida en la última tecla. Con .com.es serían más.

Y el esquema entero se valida en cada campo que cambia. Si tienes un refine asíncrono, escribir en otro campo también lo dispara. Por eso:

  • Usa mode: 'onBlur' o 'onSubmit' en estos formularios.
  • Guarda las respuestas en un Map si el mismo valor puede repetirse.
  • No lo hagas salvo que aporte: el servidor tiene que comprobarlo igualmente al guardar, y la sección 10 ya lleva ese error a su campo.

isValidating permite enseñar “Comprobando…”.

⚠️ Importante: safeParse con un refine asíncrono no devuelve un error de validación, sino que lanza: Encountered Promise during synchronous parse. Tienes que usar safeParseAsync. Por eso tampoco funciona mode: 'sync' en el tercer argumento de zodResolver.


16. Comparación con las Actions 🆕 19.0

🆕 Desde React 19.0. useActionState, useFormStatus, <form action> y las transiciones asíncronas no existen en 18.3. Si usas una versión anterior: el enfoque de esta sección no está disponible. Usa RHF con handleSubmit, que funciona igual, y gestiona la espera con isSubmitting.

En el módulo 16 resolviste formularios sin biblioteca. Vamos a escribir lo mismo de las dos formas, usando el mismo esquema:

// src/esquemaDepartamento.ts
import { z } from 'zod';

export const EsquemaDepartamento = z.object({
  nombre: z.string().trim().min(2, 'Escribe al menos 2 letras'),
  localidad: z.string().trim().min(2, 'Escribe la localidad'),
});

export type Departamento = z.infer<typeof EsquemaDepartamento>;

Con useActionState

// src/AccionConZod.tsx
import { useActionState } from 'react';
import { useFormStatus } from 'react-dom';
import { z } from 'zod';
import { ErrorApi, pedir } from './api.ts';
import { EsquemaDepartamento } from './esquemaDepartamento.ts';
import type { Departamento } from './esquemaDepartamento.ts';

interface Estado {
  errores: Partial<Record<keyof Departamento, string[]>>;
  aviso: string | null;
  valores: { nombre: string; localidad: string };
}

const INICIAL: Estado = { errores: {}, aviso: null, valores: { nombre: '', localidad: '' } };

async function crear(_anterior: Estado, formData: FormData): Promise<Estado> {
  const valores = {
    nombre: String(formData.get('nombre') ?? ''),
    localidad: String(formData.get('localidad') ?? ''),
  };

  const resultado = EsquemaDepartamento.safeParse(valores);
  if (!resultado.success) {
    return { errores: z.flattenError(resultado.error).fieldErrors, aviso: null, valores };
  }

  try {
    const nuevo = await pedir<{ deptId: number }>('/departamentos', 'POST', resultado.data);
    return { ...INICIAL, aviso: `Departamento ${nuevo.deptId} creado` };
  } catch (error) {
    if (error instanceof ErrorApi && error.status === 422) {
      const { nombre } = error.detalles;
      return { errores: { nombre: nombre ? [nombre] : [] }, aviso: null, valores };
    }
    return { errores: {}, aviso: 'No se ha podido guardar', valores };
  }
}

function BotonGuardar() {
  const { pending } = useFormStatus();
  return <button type="submit" disabled={pending}>{pending ? 'Guardando...' : 'Crear'}</button>;
}

export default function AccionConZod() {
  const [estado, accion] = useActionState(crear, INICIAL);

  return (
    <form action={accion} noValidate>
      <h2>Con useActionState</h2>
      <div className="campo">
        <label htmlFor="nombre">Nombre</label>
        <input id="nombre" name="nombre" defaultValue={estado.valores.nombre} />
        {estado.errores.nombre?.map((m) => <p className="error" key={m}>{m}</p>)}
      </div>
      <div className="campo">
        <label htmlFor="localidad">Localidad</label>
        <input id="localidad" name="localidad" defaultValue={estado.valores.localidad} />
        {estado.errores.localidad?.map((m) => <p className="error" key={m}>{m}</p>)}
      </div>
      <BotonGuardar />
      {estado.aviso && <p role="status">{estado.aviso}</p>}
    </form>
  );
}

Esto sigue el modelo de la plataforma: FormData, sin renders mientras escribes y con el reinicio automático de React. A cambio, todo lo demás lo escribes tú: defaultValue={estado.valores.nombre} existe porque tras la acción React vacía el formulario, y tampoco tienes validación mientras se escribe, foco en el primer error, isDirty ni listas dinámicas. En cambio, z.flattenError(...).fieldErrors encaja de maravilla: devuelve justo un objeto con los mensajes de cada campo.

RHF dentro de una transición

Si quieres isPending o useOptimistic, ambos mundos se pueden combinar. handleSubmit valida y tu función abre la transición:

// src/RhfConTransicion.tsx
import { useState, useTransition } from 'react';
import { useFormStatus } from 'react-dom';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { pedir } from './api.ts';
import { EsquemaDepartamento } from './esquemaDepartamento.ts';
import type { Departamento } from './esquemaDepartamento.ts';
import { volcarErrores } from './erroresServidor.ts';
import { Entrada } from './Entrada.tsx';

function EstadoDelFormulario() {
  const { pending } = useFormStatus();
  return <p data-prueba="status">useFormStatus: {String(pending)}</p>;
}

export default function RhfConTransicion() {
  const [pendiente, iniciar] = useTransition();
  const [aviso, setAviso] = useState<string | null>(null);
  const {
    register,
    handleSubmit,
    reset,
    setError,
    getValues,
    formState: { errors, isSubmitting },
  } = useForm<Departamento>({
    resolver: zodResolver(EsquemaDepartamento),
    defaultValues: { nombre: '', localidad: '' },
  });

  function guardar(datos: Departamento) {
    iniciar(async () => {
      try {
        const nuevo = await pedir<{ deptId: number }>('/departamentos', 'POST', datos);
        reset();
        setAviso(`Departamento ${nuevo.deptId} creado`);
      } catch (error) {
        volcarErrores(error, setError, getValues);
      }
    });
  }

  return (
    <form onSubmit={handleSubmit(guardar)} noValidate>
      <h2>RHF con una transición</h2>
      <Entrada etiqueta="Nombre" error={errors.nombre?.message} {...register('nombre')} />
      <Entrada etiqueta="Localidad" error={errors.localidad?.message} {...register('localidad')} />
      <button type="submit" disabled={pendiente}>{pendiente ? 'Guardando...' : 'Crear'}</button>
      <p data-prueba="pendiente">isPending: {String(pendiente)} / isSubmitting: {String(isSubmitting)}</p>
      <EstadoDelFormulario />
      {aviso && <p role="status">{aviso}</p>}
    </form>
  );
}

Lo medido durante el envío, con la respuesta retrasada 500 ms:

isPending: true / isSubmitting: false
useFormStatus: false

Las dos líneas enseñan algo:

  • isSubmitting está en false. guardar no devuelve la promesa: iniciar la consume, así que para RHF el envío ha terminado al instante. Si dependes de isSubmitting, quita la transición y usa una función async, como en el resto del módulo. Aquí el indicador es pendiente.
  • useFormStatus no sirve con onSubmit. Solo sabe de <form action>.

setError y reset funcionan dentro de la transición: la prueba muestra el error de nombre repetido con el foco en su campo, y el formulario vacío tras crear.

Qué elegir

Necesitas Actions RHF con Zod
Sin dependencias Sí No: dos paquetes
Funcionar sin JavaScript, con Server Functions Sí No de serie
Formularios cortos: 1 a 5 campos Muy adecuado Sobra
Validar en el cliente al salir o al escribir A mano De serie
Foco en el primer error A mano De serie
isDirty, dirtyFields, touchedFields A mano De serie
Listas de campos A mano, costoso useFieldArray
Formularios de muchos campos Posible Muy adecuado
Actualización optimista useOptimistic Con una transición
Mismo esquema en cliente y servidor Sí, con Zod Sí, con Zod

No son rivales. Zod es la pieza común y funciona con los dos. Una regla razonable: un buscador, un inicio de sesión o una suscripción se quedan en Actions; una ficha de empleado con 15 campos o un pedido con líneas van con RHF.

💡 Consejo: RHF también exporta un componente <Form> que envía a una URL sin escribir handleSubmit, con propiedades como action, headers y onError. Y useForm tiene una opción progressive para formularios que deben funcionar antes de que cargue JavaScript. No las usamos en este curso. Consulta sus tipos si tu proyecto las necesita.


17. Errores frecuentes y depuración

Síntoma Causa Solución
El campo no envía su valor o dice estar vacío El componente no pasa ref al <input> Reparte {...resto} o pasa ref
register('a', { required }) no hace nada Hay un resolver Pon la regla en el esquema
El error no sale y el botón parece muerto Falla un campo que no enseñas console.log(errors) o el segundo argumento de handleSubmit
El número llega como texto Falta valueAsNumber Añádelo, o setValueAs
Sale NaN Campo numérico vacío con valueAsNumber Valida con z.number() y protege los cálculos
Se guarda un 0 que nadie escribió z.coerce.number() con '' Usa valueAsNumber y z.number()
.optional() falla en un campo vacío El vacío es '' Usa z.union([z.literal(''), ...])
isDirty es true sin cambios Falta un campo en defaultValues, o un valor difiere en tipo Da un valor por defecto a todos los campos
isDirty no se actualiza con setValue No se pidió setValue('x', v, { shouldDirty: true })
defaultValues nuevos no se aplican Solo se leen al crear el formulario reset(datos), values o key
El aviso uncontrolled to controlled Controller sin valor por defecto Pon el campo en defaultValues
El campo con disabled desaparece de los datos register('a', { disabled: true }) lo deja undefined Usa readOnly, que sí envía el valor
Una fila borrada deja valores cruzados key={i} en la lista key={field.id}
errors.lineas.message vacío El error está en .root errors.lineas?.root?.message
isSubmitting no cambia Tu función no devuelve la promesa Hazla async y usa await
Un formulario dentro de otro envía los dos <form> anidados, no válidos en HTML Sepáralos; un portal no basta, porque el evento sigue subiendo por el árbol de React
TypeScript rechaza defaultValues con transformaciones Falta el tipo de entrada useForm<Entrada, unknown, Salida>
Encountered Promise during synchronous parse safeParse con refine asíncrono safeParseAsync

Un detalle sobre disabled, medido con tres campos: register('a', { disabled: true }) hace que a no llegue a los datos, mientras que la prop disabled escrita directamente en el <input> y readOnly sí conservan el valor. Si quieres enseñar un dato sin dejar editarlo, usa readOnly.

Cómo investigar

  1. Mira los errores con console.log(errors). Es lo primero que hay que hacer si el botón “no hace nada”. No uses JSON.stringify(errors): cada error guarda una referencia a su nodo del DOM y lanza Converting circular structure to JSON.
  2. Pasa el segundo argumento a handleSubmit: handleSubmit(guardar, console.log). Si se ejecuta el segundo, el problema es de validación, no de envío.
  3. Prueba el esquema aparte, con node o con un console.log(Esquema.safeParse(getValues())). Es mucho más rápido que rellenar el formulario.
  4. Comprueba el ref en las React DevTools si un campo se comporta raro.

18. Ejercicios

Ejercicio 1: Nuevo departamento

Crea un formulario de alta de departamento sin Zod, solo con reglas de register: nombre obligatorio de al menos 3 letras, localidad obligatoria y presupuesto no negativo. Valida con onTouched, manda el nombre en mayúsculas y muestra el error de la API si el nombre ya existe, con el foco en el campo.

Ver solución
// src/Ej1Departamento.tsx
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { ErrorApi, pedir } from './api.ts';

interface NuevoDepartamento {
  nombre: string;
  localidad: string;
  presupuesto: number;
}

export default function Ej1Departamento() {
  const [creado, setCreado] = useState<number | null>(null);
  const {
    register,
    handleSubmit,
    reset,
    setError,
    formState: { errors, isSubmitting },
  } = useForm<NuevoDepartamento>({
    mode: 'onTouched',
    defaultValues: { nombre: '', localidad: '' },
  });

  async function guardar(datos: NuevoDepartamento) {
    setCreado(null);
    try {
      const nuevo = await pedir<{ deptId: number }>('/departamentos', 'POST', {
        ...datos,
        nombre: datos.nombre.toUpperCase(),
      });
      setCreado(nuevo.deptId);
      reset();
    } catch (error) {
      if (error instanceof ErrorApi && error.detalles.nombre) {
        setError('nombre', { type: 'servidor', message: error.detalles.nombre }, { shouldFocus: true });
      } else {
        setError('root.servidor', { message: 'No se ha podido guardar' });
      }
    }
  }

  return (
    <form onSubmit={handleSubmit(guardar)} noValidate>
      <h2>Nuevo departamento</h2>
      {errors.root?.servidor && <p className="aviso" role="alert">{errors.root.servidor.message}</p>}

      <div className="campo">
        <label htmlFor="nombre">Nombre</label>
        <input
          id="nombre"
          aria-invalid={errors.nombre ? true : undefined}
          {...register('nombre', { required: 'Obligatorio', minLength: { value: 3, message: 'Mínimo 3 letras' } })}
        />
        {errors.nombre && <p className="error">{errors.nombre.message}</p>}
      </div>
      <div className="campo">
        <label htmlFor="localidad">Localidad</label>
        <input id="localidad" {...register('localidad', { required: 'Obligatoria' })} />
        {errors.localidad && <p className="error">{errors.localidad.message}</p>}
      </div>
      <div className="campo">
        <label htmlFor="presupuesto">Presupuesto</label>
        <input
          id="presupuesto"
          type="number"
          {...register('presupuesto', {
            valueAsNumber: true,
            min: { value: 0, message: 'No puede ser negativo' },
          })}
        />
        {errors.presupuesto && <p className="error">{errors.presupuesto.message}</p>}
      </div>

      <button type="submit" disabled={isSubmitting}>Crear</button>
      {creado !== null && <p className="exito" role="status">Departamento {creado} creado</p>}
    </form>
  );
}

Con ventas, que en mayúsculas ya existe, el campo muestra “Ya existe un departamento con ese nombre” y recibe el foco. La API compara sin distinguir mayúsculas, pero guardamos en mayúsculas porque es el formato de los datos existentes. Aquí no hace falta volcarErrores, ya que solo esperamos un tipo de error.

Ejercicio 2: Cambiar la contraseña

Crea un formulario con la contraseña actual, la nueva y su repetición. La nueva debe tener al menos 8 caracteres, una mayúscula y un número, y hay que enseñar todos los fallos a la vez como una lista. Además, debe coincidir con la repetición y ser distinta de la actual.

Ver solución
// src/Ej2Contrasena.tsx
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Entrada } from './Entrada.tsx';

const EsquemaClave = z
  .object({
    actual: z.string().min(1, 'Escribe tu contraseña actual'),
    nueva: z.string().superRefine((valor, ctx) => {
      if (valor.length < 8) ctx.addIssue({ code: 'custom', message: 'Mínimo 8 caracteres' });
      if (!/[A-Z]/.test(valor)) ctx.addIssue({ code: 'custom', message: 'Al menos una mayúscula' });
      if (!/\d/.test(valor)) ctx.addIssue({ code: 'custom', message: 'Al menos un número' });
    }),
    repetida: z.string(),
  })
  .superRefine((datos, ctx) => {
    if (datos.nueva !== datos.repetida) {
      ctx.addIssue({ code: 'custom', message: 'Las contraseñas no coinciden', path: ['repetida'] });
    }
    if (datos.nueva === datos.actual) {
      ctx.addIssue({ code: 'custom', message: 'Debe ser distinta de la actual', path: ['nueva'] });
    }
  });

type DatosClave = z.infer<typeof EsquemaClave>;

export default function Ej2Contrasena() {
  const [cambiada, setCambiada] = useState(false);
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<DatosClave>({
    resolver: zodResolver(EsquemaClave),
    criteriaMode: 'all', // reúne todos los fallos de cada campo en errors.campo.types
    mode: 'onTouched',
    defaultValues: { actual: '', nueva: '', repetida: '' },
  });

  // types agrupa por código: varios "custom" llegan como una lista
  const fallosNueva = Object.values(errors.nueva?.types ?? {}).flat();

  return (
    <form onSubmit={handleSubmit(() => setCambiada(true))} noValidate>
      <h2>Cambiar contraseña</h2>
      <Entrada etiqueta="Contraseña actual" type="password" error={errors.actual?.message} {...register('actual')} />
      <Entrada etiqueta="Contraseña nueva" type="password" {...register('nueva')} />
      <ul data-prueba="fallos">
        {fallosNueva.map((fallo) => <li className="error" key={String(fallo)}>{fallo}</li>)}
      </ul>
      <Entrada etiqueta="Repite la contraseña" type="password" error={errors.repetida?.message} {...register('repetida')} />
      <button type="submit">Cambiar</button>
      {cambiada && <p className="exito" role="status">Contraseña cambiada</p>}
    </form>
  );
}

criteriaMode: 'all' reúne todos los problemas en errors.nueva.types, agrupados por código. Como los tres son custom, llegan en una lista, y .flat() deja un array sencillo. Con abc, abd y una actual válida, la prueba enseña: “Mínimo 8 caracteres”, “Al menos una mayúscula”, “Al menos un número” y “Las contraseñas no coinciden”.

Las reglas entre campos van en el superRefine del objeto, con path para asignar el error a su campo.

Ejercicio 3: Esquema de reserva

Escribe un esquema, sin formulario, para una reserva. Recibe solo cadenas, porque viene de FormData. titular se limpia y sale en mayúsculas. personas llega como texto y sale como entero de 1 a 12, y si está vacío debe decir “Indica cuántas personas”, no un error numérico. salida debe ser posterior a entrada. codigo es opcional y un vacío debe salir como null.

Ver solución
// src/ej3-reserva.ts
import { z } from 'zod';

export const EsquemaReserva = z
  .object({
    titular: z.string().trim().min(2, 'Escribe el titular').transform((t) => t.toUpperCase()),
    personas: z
      .string()
      .min(1, 'Indica cuántas personas')
      .pipe(z.coerce.number<string>('Escribe un número').int('Debe ser entero').min(1, 'Mínimo 1').max(12, 'Máximo 12')),
    entrada: z.iso.date('Fecha no válida'),
    salida: z.iso.date('Fecha no válida'),
    codigo: z
      .string()
      .trim()
      .transform((c) => c || null),
  })
  .refine((r) => r.salida > r.entrada, {
    error: 'La salida debe ser posterior a la entrada',
    path: ['salida'],
  });

type Entrada = z.input<typeof EsquemaReserva>;
type Salida = z.output<typeof EsquemaReserva>;

const buena: Entrada = {
  titular: ' ana garcía ', personas: '4', entrada: '2026-12-01', salida: '2026-12-05', codigo: ' ',
};
const resultado: Salida = EsquemaReserva.parse(buena);
console.log(resultado);

const mala = EsquemaReserva.safeParse({
  titular: 'a', personas: '', entrada: '2026-12-05', salida: '2026-12-01', codigo: '',
});
console.log(z.flattenError(mala.error!).fieldErrors);

const texto = EsquemaReserva.safeParse({ ...buena, personas: 'dos' });
console.log(z.flattenError(texto.error!).fieldErrors);
{
  titular: 'ANA GARCÍA',
  personas: 4,
  entrada: '2026-12-01',
  salida: '2026-12-05',
  codigo: null
}
{
  titular: [ 'Escribe el titular' ],
  personas: [ 'Indica cuántas personas' ],
  salida: [ 'La salida debe ser posterior a la entrada' ]
}
{ personas: [ 'Escribe un número' ] }

.min(1) antes del pipe es la clave: detiene el vacío antes de que coerce lo convierta en 0. El <string> de z.coerce.number<string> es necesario para que el pipe compile, como vimos en la sección 9. Las fechas AAAA-MM-DD se pueden comparar como texto porque su orden alfabético coincide con el cronológico.

Ejercicio 4: Selector de cantidad genérico

Crea un componente SelectorCantidad con botones de más y menos, con useController, que sirva para cualquier formulario: debe recibir name y control con tipos genéricos. Úsalo en un formulario de entradas con al menos un adulto y un máximo de dos niños por adulto.

Ver solución
// src/Ej4Cantidad.tsx
import { useState } from 'react';
import { useController, useForm } from 'react-hook-form';
import type { Control, FieldPath, FieldValues } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

interface PropsCantidad<T extends FieldValues> {
  name: FieldPath<T>;
  control: Control<T>;
  etiqueta: string;
  maximo: number;
}

// Genérico: sirve para cualquier formulario cuyo campo guarde un número
function SelectorCantidad<T extends FieldValues>({ name, control, etiqueta, maximo }: PropsCantidad<T>) {
  const { field, fieldState } = useController({ name, control });
  const valor = field.value as number;

  return (
    <div className="campo">
      <span id={`${name}-etiqueta`}>{etiqueta}</span>
      <div className="fila" role="group" aria-labelledby={`${name}-etiqueta`} onBlur={field.onBlur}>
        <button type="button" aria-label="Menos" disabled={valor <= 0} onClick={() => field.onChange(valor - 1)}>−</button>
        <output data-prueba={name}>{valor}</output>
        <button type="button" ref={field.ref} aria-label="Más" disabled={valor >= maximo} onClick={() => field.onChange(valor + 1)}>+</button>
      </div>
      {fieldState.error && <p className="error">{fieldState.error.message}</p>}
    </div>
  );
}

const EsquemaEntradas = z
  .object({
    adultos: z.number().min(1, 'Al menos un adulto'),
    ninos: z.number(),
  })
  .refine((d) => d.ninos <= d.adultos * 2, {
    error: 'Máximo dos niños por adulto',
    path: ['ninos'],
  });

type Entradas = z.infer<typeof EsquemaEntradas>;

export default function Ej4Cantidad() {
  const [enviado, setEnviado] = useState<Entradas | null>(null);
  const { control, handleSubmit } = useForm<Entradas>({
    resolver: zodResolver(EsquemaEntradas),
    defaultValues: { adultos: 0, ninos: 0 },
  });

  return (
    <form onSubmit={handleSubmit(setEnviado)} noValidate>
      <h2>Entradas</h2>
      <SelectorCantidad control={control} name="adultos" etiqueta="Adultos" maximo={6} />
      <SelectorCantidad control={control} name="ninos" etiqueta="Niños" maximo={6} />
      <button type="submit">Comprar</button>
      {enviado && <pre>{JSON.stringify(enviado)}</pre>}
    </form>
  );
}

Los genéricos <T extends FieldValues> y FieldPath<T> hacen que name="adultoss" no compile. El as number es necesario porque, siendo genérico, TypeScript no puede saber qué tipo tiene el campo. Al enviar con cero adultos, el foco va al botón Más, el elemento que recibe field.ref, que es justo lo que el usuario debe pulsar.

Ejercicio 5: Equipo de proyecto

Crea un formulario de equipo con useFieldArray: nombre y horas por persona, entre 2 y 5 personas, y un máximo de 1000 horas en total. Hay que poder intercambiar filas, insertar una tras otra y ver la suma en vivo.

Ver solución
// src/Ej5Equipo.tsx
import { useState } from 'react';
import { useFieldArray, useForm, useWatch } from 'react-hook-form';
import type { Control } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

const HORAS_MAXIMAS = 1000;

const EsquemaEquipo = z.object({
  proyecto: z.string().trim().min(2, 'Escribe el nombre'),
  miembros: z
    .array(
      z.object({
        nombre: z.string().trim().min(2, 'Falta el nombre'),
        horas: z.number('Escribe las horas').positive('Mayor que cero'),
      }),
    )
    .min(2, 'Un equipo son al menos dos personas')
    .max(5, 'Máximo cinco personas')
    .refine((m) => m.reduce((suma, x) => suma + x.horas, 0) <= HORAS_MAXIMAS, {
      error: `El equipo no puede pasar de ${HORAS_MAXIMAS} horas`,
    }),
});

type Equipo = z.infer<typeof EsquemaEquipo>;

function SumaHoras({ control }: { control: Control<Equipo> }) {
  const suma = useWatch({
    control,
    compute: ({ miembros }) => miembros.reduce((total, m) => total + (m.horas || 0), 0),
  });
  return <p>Horas: <strong data-prueba="horas">{suma}</strong> de {HORAS_MAXIMAS}</p>;
}

export default function Ej5Equipo() {
  const [enviado, setEnviado] = useState<Equipo | null>(null);
  const {
    register,
    control,
    handleSubmit,
    formState: { errors },
  } = useForm<Equipo>({
    resolver: zodResolver(EsquemaEquipo),
    defaultValues: { proyecto: '', miembros: [{ nombre: 'Ana', horas: 100 }] },
  });
  const { fields, append, remove, swap, insert } = useFieldArray({ control, name: 'miembros' });

  return (
    <form onSubmit={handleSubmit(setEnviado)} noValidate>
      <h2>Equipo de proyecto</h2>
      <div className="campo">
        <label htmlFor="proyecto">Proyecto</label>
        <input id="proyecto" {...register('proyecto')} />
        {errors.proyecto && <p className="error">{errors.proyecto.message}</p>}
      </div>

      {fields.map((field, i) => (
        <div className="fila" key={field.id}>
          <input aria-label={`Nombre ${i + 1}`} {...register(`miembros.${i}.nombre`)} />
          <input aria-label={`Horas ${i + 1}`} type="number" style={{ width: 90 }} {...register(`miembros.${i}.horas`, { valueAsNumber: true })} />
          <button type="button" disabled={i === fields.length - 1} onClick={() => swap(i, i + 1)} aria-label={`Bajar ${i + 1}`}>↓</button>
          <button type="button" onClick={() => insert(i + 1, { nombre: '', horas: 0 })} aria-label={`Insertar tras ${i + 1}`}>+</button>
          <button type="button" onClick={() => remove(i)} aria-label={`Quitar ${i + 1}`}>✕</button>
          {errors.miembros?.[i]?.nombre && <p className="error">{errors.miembros[i].nombre.message}</p>}
          {errors.miembros?.[i]?.horas && <p className="error">{errors.miembros[i].horas.message}</p>}
        </div>
      ))}
      {errors.miembros?.root && <p className="error" role="alert">{errors.miembros.root.message}</p>}

      <div className="fila">
        <button type="button" disabled={fields.length >= 5} onClick={() => append({ nombre: '', horas: 0 })}>
          Añadir persona
        </button>
      </div>

      <SumaHoras control={control} />
      <button type="submit">Guardar</button>
      {enviado && <pre>{JSON.stringify(enviado)}</pre>}
    </form>
  );
}

El refine del array se lee en errors.miembros.root. Con 100 y 950 horas, el mensaje es “El equipo no puede pasar de 1000 horas”. SumaHoras usa useWatch con compute, de modo que solo se vuelve a pintar ese fragmento.

Ejercicio 6: Formulario generado

Genera un formulario a partir de una lista de definiciones, como si llegara de una API: texto, número y email, cada uno con sus límites. Construye también el esquema de Zod a partir de esa lista.

Ver solución
// src/Ej6Metadatos.tsx
import { useMemo, useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Entrada } from './Entrada.tsx';

// La descripción del formulario son datos: podrían venir de una API
type DefinicionCampo =
  | { nombre: string; etiqueta: string; tipo: 'texto'; minimo: number }
  | { nombre: string; etiqueta: string; tipo: 'numero'; minimo: number; maximo: number }
  | { nombre: string; etiqueta: string; tipo: 'email' };

const CAMPOS: DefinicionCampo[] = [
  { nombre: 'nombre', etiqueta: 'Nombre', tipo: 'texto', minimo: 2 },
  { nombre: 'correo', etiqueta: 'Correo', tipo: 'email' },
  { nombre: 'edad', etiqueta: 'Edad', tipo: 'numero', minimo: 18, maximo: 67 },
];

function esquemaDeCampo(campo: DefinicionCampo): z.ZodType {
  switch (campo.tipo) {
    case 'texto':
      return z.string().min(campo.minimo, `Mínimo ${campo.minimo} caracteres`);
    case 'numero':
      return z
        .number('Escribe un número')
        .min(campo.minimo, `Mínimo ${campo.minimo}`)
        .max(campo.maximo, `Máximo ${campo.maximo}`);
    case 'email':
      return z.email('El email no es válido');
  }
}

function construirEsquema(campos: DefinicionCampo[]) {
  return z.object(Object.fromEntries(campos.map((c) => [c.nombre, esquemaDeCampo(c)])));
}

type Valores = Record<string, unknown>;

function FormularioGenerado({ campos }: { campos: DefinicionCampo[] }) {
  const [enviado, setEnviado] = useState<Valores | null>(null);
  const esquema = useMemo(() => construirEsquema(campos), [campos]);
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<Valores>({ resolver: zodResolver(esquema) });

  return (
    <form onSubmit={handleSubmit(setEnviado)} noValidate>
      <h2>Formulario generado</h2>
      {campos.map((campo) => (
        <Entrada
          key={campo.nombre}
          etiqueta={campo.etiqueta}
          type={campo.tipo === 'numero' ? 'number' : campo.tipo === 'email' ? 'email' : 'text'}
          error={errors[campo.nombre]?.message}
          {...register(campo.nombre, { valueAsNumber: campo.tipo === 'numero' })}
        />
      ))}
      <button type="submit">Enviar</button>
      {enviado && <pre>{JSON.stringify(enviado)}</pre>}
    </form>
  );
}

export default function Ej6Metadatos() {
  return <FormularioGenerado campos={CAMPOS} />;
}

Object.fromEntries convierte la lista en la forma que espera z.object. Los esquemas se pueden construir en ejecución porque son objetos normales, y a cambio pierdes los tipos: por eso los valores son Record<string, unknown>. useMemo evita reconstruir el esquema en cada render.

La unión discriminada DefinicionCampo hace que switch conozca maximo solo en la rama numero.

Ejercicio 7: Con TanStack Query

Haz un alta de departamento que use useMutation de TanStack Query: debe seguir mostrando los errores del servidor en su campo, y al crear debe actualizar el contador de departamentos que se ve en el título.

Ver solución
// src/Ej7Mutacion.tsx
import { QueryClient, QueryClientProvider, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { pedir } from './api.ts';
import { EsquemaDepartamento } from './esquemaDepartamento.ts';
import type { Departamento } from './esquemaDepartamento.ts';
import { volcarErrores } from './erroresServidor.ts';
import { Entrada } from './Entrada.tsx';

interface Pagina {
  total: number;
}

function Formulario() {
  const clienteQuery = useQueryClient();
  const { data } = useQuery({
    queryKey: ['departamentos'],
    queryFn: () => pedir<Pagina>('/departamentos'),
  });
  const crear = useMutation({
    mutationFn: (datos: Departamento) => pedir('/departamentos', 'POST', datos),
    onSuccess: () => clienteQuery.invalidateQueries({ queryKey: ['departamentos'] }),
  });
  const {
    register,
    handleSubmit,
    reset,
    setError,
    getValues,
    formState: { errors, isSubmitting },
  } = useForm<Departamento>({
    resolver: zodResolver(EsquemaDepartamento),
    defaultValues: { nombre: '', localidad: '' },
  });

  async function guardar(datos: Departamento) {
    try {
      await crear.mutateAsync(datos); // devuelve una promesa: isSubmitting la espera
      reset();
    } catch (error) {
      volcarErrores(error, setError, getValues);
    }
  }

  return (
    <form onSubmit={handleSubmit(guardar)} noValidate>
      <h2>Departamentos: <span data-prueba="total">{data?.total ?? '...'}</span></h2>
      {errors.root?.servidor && <p className="aviso" role="alert">{errors.root.servidor.message}</p>}
      <Entrada etiqueta="Nombre" error={errors.nombre?.message} {...register('nombre')} />
      <Entrada etiqueta="Localidad" error={errors.localidad?.message} {...register('localidad')} />
      <button type="submit" disabled={isSubmitting}>Crear</button>
    </form>
  );
}

const clienteQuery = new QueryClient();

export default function Ej7Mutacion() {
  return (
    <QueryClientProvider client={clienteQuery}>
      <Formulario />
    </QueryClientProvider>
  );
}

La clave es mutateAsync: devuelve una promesa, y con ella funcionan a la vez await, try/catch e isSubmitting. Con mutate, que no devuelve nada, guardar terminaría al instante. La invalidación de onSuccess vuelve a pedir el total: la prueba pasó de 10 a 11 departamentos.

Ejercicio 8 (propuesto): Borrador que sobrevive

Con subscribe, guarda en localStorage un borrador de NuevoPedido. Al montar, si hay un borrador, ofrece recuperarlo con reset. Bórralo al crear el pedido. Piensa en qué pasa con los NaN, que JSON.stringify convierte en null.

Ejercicio 9 (propuesto): Asistente con saltos

Añade a AsistenteAlta unas pestañas para saltar a cualquier paso. Usa handleSubmit(alAcertar, alFallar) para volver al primer paso con error, y marca en cada pestaña si tiene errores.

Ejercicio 10 (propuesto): Edición con PATCH

Toma el formulario de edición de la sección 7 y envía solo los campos modificados, usando dirtyFields. Escribe una función genérica soloModificados(datos, dirtyFields). La API EMPRESA no tiene PATCH para departamentos, así que imprime el resultado en lugar de enviarlo.


Resumen

Concepto API Idea clave
Valor en el DOM useForm, register Sin render por cada tecla
Enviar handleSubmit(alAcertar, alFallar) Valida, y solo entonces llama a tu función
Reglas required, min, pattern, validate Se ignoran si hay resolver
Tipos de valor valueAsNumber, valueAsDate, setValueAs Un número vacío es NaN
Cuándo validar mode, reValidateMode onTouched suele ser la mejor opción
Estado formState Es un proxy: léelo en el render
Observar useWatch, getValues, subscribe Mejor en un hijo que watch en el padre
Cambiar setValue, reset, trigger, setFocus reset(datos) fija los nuevos valores por defecto
Editar defaultValues asíncronos, values key={id} para cambiar de registro
Esquemas z.object, z.email, z.iso.date Un fallo de formato no detiene las reglas siguientes
Texto a número valueAsNumber con z.number z.coerce.number convierte '' en 0
Reglas entre campos refine, superRefine con path Toleran datos con mal formato
Conexión zodResolver useForm<Entrada, unknown, Salida> si hay transform
Errores de la API setError, root.servidor Cada error, donde se pueda corregir
Componentes propios Controller, useController Necesitan value, onChange, onBlur y ref
Dividir FormProvider, useFormContext, useFormState El campo debe recibir ref
Listas useFieldArray, fields, append, remove, move key={field.id}; error de lista en .root
Campos condicionales z.discriminatedUnion, shouldUnregister Lo oculto deja de existir si tú quieres
Por pasos trigger(campos) Un solo useForm para todo el asistente
Validación remota refine asíncrono, abort: true Cuenta las peticiones; prefiere onBlur
Sin biblioteca useActionState, z.flattenError Ideal para formularios cortos
Combinar handleSubmit con useTransition isSubmitting queda en false