El capítulo anterior cargabas héroes desde un servidor con TanStack Query. Ahora le toca al otro lado: recoger datos del usuario. El Team Builder necesita un formulario para añadir héroes nuevos al roster: nombre, rol, partidas y victorias.
Podrías hacerlo con un useState por campo, como ya sabes del capítulo 5. Funciona. Pero antes de pasar a react-hook-form, merece la pena ver con exactitud cuál es el coste de ese enfoque y por qué aparece una librería específica para formularios.
El problema de los inputs controlados a escala#
Con useState por campo, un formulario de cuatro campos se ve así:
// Cuatro estados, uno por campo.
const [nombre, setNombre] = useState("");
const [rol, setRol] = useState("");
const [partidas, setPartidas] = useState(0);
const [victorias, setVictorias] = useState(0);
// Cuatro manejadores, uno por campo.
function onSubmit(evento: React.FormEvent) {
// preventDefault manual para que el navegador no recargue.
evento.preventDefault();
// Validaciones con if, una por regla.
if (nombre.length < 2) { ... }
if (!["Daño", "Apoyo", "Tanque"].includes(rol)) { ... }
// ...
}Son doce líneas de fontanería antes de escribir una sola regla de negocio. Y el problema de rendimiento: con inputs controlados, cada pulsación de tecla actualiza el estado y eso dispara un re-render de todo el componente del formulario. Con cuatro campos apenas se nota. Pero el patrón se repite: en un formulario real de veinte campos con validación en tiempo real, la acumulación de re-renders se nota como lag al teclear.
Pruébalo: el re-render en cada tecla#
Fíjate en el console.log("Re-render") dentro del componente. Empieza a escribir en cualquier campo. El mensaje aparece en la consola de abajo en cada pulsación, porque cada tecla actualiza un estado y React re-renderiza el componente entero. Con cuatro campos y sin validación pesada no es un problema, pero es el síntoma del patrón.
react-hook-form: inputs no controlados con la ergonomía de los controlados#
react-hook-form (RHF) resuelve el problema cambiando el modelo: en vez de value={estado} y onChange, conecta los inputs con refs internas. Una ref es una referencia directa al nodo del DOM: RHF la mantiene actualizada pero cambiarla no dispara un re-render. El valor del input vive en el DOM, no en el estado de React.
El resultado: teclear no re-renderiza el componente. Solo se re-renderiza cuando RHF necesita mostrar o quitar errores. En un formulario de veinte campos con validación, la diferencia es perceptible.
El setup mínimo de RHF tiene tres piezas:
import { useForm } from "react-hook-form";
const { register, handleSubmit, formState: { errors } } = useForm<DatosFormulario>({
resolver: zodResolver(schema),
});register(nombre): devuelve los props que el input necesita para conectarse con RHF (ref,name,onChange,onBlur). Se expande con{...register("nombre")}.handleSubmit(onValid): envuelve el submit. HacepreventDefault, valida con el resolver y llama aonValidsolo si todos los campos pasan. Si hay errores, los pone enformState.errorsy no llama aonValid.formState.errors: un objeto con los mensajes de error por campo.errors.nombre?.messageexiste solo si Zod rechazó ese campo.
Zod como esquema y como tipo: la fuente única de verdad#
La otra pieza es por qué usar Zod en vez de validar con ifs dentro de onSubmit.
Cuando validas a mano, las reglas viven en dos sitios: el tipo TypeScript (interface DatosFormulario { nombre: string; partidas: number; ... }) y los ifs de validación. Si añades un campo nuevo al interface y te olvidas de añadir su validación, TypeScript no lo detecta: los dos sistemas son independientes y pueden desincronizarse.
Con Zod, hay un solo sitio:
// El schema define las reglas.
const schema = z.object({
nombre: z.string().min(2, "El nombre debe tener al menos 2 caracteres"),
// ...
});
// z.infer extrae el tipo TypeScript equivalente.
// Si cambias el schema, el tipo cambia solo: sin tocar nada más.
type DatosFormulario = z.infer<typeof schema>;El esquema es la fuente de verdad. Añades un campo al z.object: el tipo y la validación se actualizan solos, al mismo tiempo.
Pruébalo: useForm + zodResolver#
Este demo tiene el formulario básico del Team Builder con RHF y un schema de dos campos. Escribe en los inputs: la consola de abajo se queda vacía, porque RHF no re-renderiza el componente al teclear. Solo aparece un mensaje cuando envías el formulario y Zod lo acepta. Compáralo con el demo anterior, donde cada tecla escupía un Re-render.
El rol del select nativo#
Un <select> nativo funciona con register exactamente igual que un <input>. No hace falta ningún componente especial:
// register funciona igual en un <select> que en un <input>.
<select id="rol" {...register("rol")}>
<option value="">Elige un rol</option>
<option value="Daño">Daño</option>
<option value="Apoyo">Apoyo</option>
<option value="Tanque">Tanque</option>
</select>
{errors.rol && <span className="error-msg">{errors.rol.message}</span>}Para el tipo del campo rol en el schema, el conjunto de valores es cerrado. Una forma de modelarlo en Zod es z.union de literales: cada z.literal acepta un valor exacto y z.union los combina en “uno de estos”.
// z.union de literales: solo acepta exactamente estos tres strings.
// Si el usuario envía otro valor (no debería con un select, pero Zod valida de todas formas),
// el resolver lo rechaza con el mensaje del segundo argumento de z.union.
rol: z.union([z.literal("Daño"), z.literal("Apoyo"), z.literal("Tanque")], {
// El segundo argumento de z.union personaliza el mensaje si el valor no encaja en ningún literal.
error: "Elige un rol válido",
}),valueAsNumber: el detalle que más se olvida#
Un <input type="number"> visualmente muestra un número, pero el DOM siempre entrega un string. event.target.value de un input con el texto “42” devuelve la cadena "42", no el número 42.
Zod con z.number() espera un number de JavaScript. Si recibe "42" (string), lo rechaza. El formulario no envía. No hay ningún mensaje de error visible que lo explique. Es un fallo silencioso clásico.
La solución es valueAsNumber: true en el segundo argumento de register:
// valueAsNumber: true le dice a RHF que convierta el string del input a number
// antes de pasárselo al resolver (Zod). Sin esto, z.number() recibe "42" y falla.
<input
type="number"
{...register("partidas", { valueAsNumber: true })}
/>Pruébalo: valueAsNumber#
Este demo registra los tipos reales de los datos en la consola de abajo. Envía el formulario con valores numéricos válidos y comprueba que el tipo es "number", no "string". Quitando valueAsNumber: true del código y enviando, verás que z.number() rechaza el dato aunque el usuario haya escrito un número.
Validación cruzada con .refine()#
La regla de negocio más obvia del Team Builder: el número de victorias no puede superar el de partidas. Esta regla es diferente a “el nombre tiene mínimo 2 caracteres”: necesita ver dos campos a la vez.
Un validador de campo individual no puede hacerlo. Cuando Zod valida el campo victorias, aún no sabe el valor de partidas: valida los campos en paralelo. Solo cuando ya tiene el objeto completo puede comparar ambos.
Para eso existe .refine():
// .refine() se encadena al z.object() ya construido.
// Recibe el objeto completo: puede ver todos los campos a la vez.
const schema = z.object({
partidas: z.number().int().min(1, "Al menos 1 partida"),
victorias: z.number().int().min(0, "No puede ser negativo"),
}).refine((d) => d.victorias <= d.partidas, {
message: "Las victorias no pueden superar las partidas jugadas",
// path indica en qué campo debe aparecer el error en el formulario.
// Sin path, errors.victorias sería undefined y el error no aparecería en ningún lado.
path: ["victorias"],
});Pruébalo: validación cruzada#
Escribe más victorias que partidas y envía. El error de .refine() aparece bajo el campo de victorias porque path: ["victorias"] lo coloca ahí. Si escribes valores válidos (victorias ≤ partidas), el formulario envía y muestra el winrate calculado.
reset(), isSubmitting y el formulario completo#
Las dos últimas piezas que completan el formulario en producción:
const { register, handleSubmit, reset, formState: { errors, isSubmitting } } = useForm<DatosFormulario>({
resolver: zodResolver(schema),
// defaultValues: el estado inicial de cada campo y el destino de reset().
defaultValues: { nombre: "", rol: undefined, partidas: undefined, victorias: undefined },
});
function onSubmit(datos: DatosFormulario) {
// Añadir el héroe...
setRoster((prev) => [...prev, { id: proximoId++, ...datos }]);
// Después de añadir, limpiar el formulario para el siguiente.
// reset() sin argumentos vuelve a los defaultValues.
reset();
}reset() devuelve todos los campos a sus defaultValues. Sin él, los campos quedarían con los valores del héroe que acabas de añadir.
isSubmitting es true mientras handleSubmit espera a que tu función onSubmit resuelva (útil cuando onSubmit hace una petición a la API). Desactivar el botón evita dobles envíos:
// disabled durante el envío: evita que el usuario pulse dos veces.
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Añadiendo..." : "Añadir héroe"}
</button>Pruébalo: formulario completo#
Este demo junta todo: schema completo, reset() tras enviar, isSubmitting, defaultValues y el roster actualizado en tiempo real. Añade varios héroes seguidos y comprueba que el formulario se limpia entre añadidos.
El patrón completo de tipado: por qué siempre useForm<Datos>#
El genérico de useForm<DatosFormulario> no es opcional si quieres los tipos correctos:
// Sin el genérico, register y errors no conocen los campos del formulario.
// register("nonbre") pasaría sin error de tipo aunque sea un typo.
const form = useForm(); // mal — genérico inferido como Record<string, any>
// Con el genérico, TypeScript comprueba que los campos existen en DatosFormulario.
const form = useForm<DatosFormulario>({ resolver: zodResolver(schema) }); // bienEl patrón completo es:
// 1. El schema es la única fuente de verdad.
const schema = z.object({ ... });
// 2. El tipo se DERIVA del schema, no se escribe a mano.
type DatosFormulario = z.infer<typeof schema>;
// 3. useForm recibe el genérico.
const { register, handleSubmit, formState } = useForm<DatosFormulario>({
resolver: zodResolver(schema),
});Comprueba lo que sabes#
Pregunta 1 de 4
¿Por qué react-hook-form evita re-renders mientras el usuario teclea, a diferencia de un formulario con useState por campo?
Tu turno#
Ejercicio · en esta página
Formulario para añadir héroes al roster
Implementa el formulario de añadir héroes al Team Builder con react-hook-form y Zod. Al enviar un formulario válido, el héroe debe aparecer en la lista; al enviar uno inválido, deben mostrarse los mensajes de error del campo correspondiente.
Paso 1: RHF + Zod básico
- Schema Zod completo: nombre (string, mín. 2 chars), rol (union de literales), partidas (number, entero, mín. 1), victorias (number, entero, mín. 0)
- useForm<DatosFormulario> con zodResolver(schema)
- register en cada campo; valueAsNumber: true en los inputs numéricos
- handleSubmit(onSubmit): al enviar válido, añade el héroe al roster
- Errores mostrados campo a campo bajo el input correspondiente
Paso 2: UX completa y accesibilidad básica
- defaultValues en useForm para que los campos empiecen vacíos de forma controlada
- reset() tras añadir el héroe: el formulario queda limpio para el siguiente
- Botón desactivado con isSubmitting para evitar envíos dobles
- htmlFor en cada label vinculado con el id del input correspondiente
- role="alert" en cada mensaje de error para que el lector de pantalla lo anuncie
Paso 3: Validación cruzada, winrate en vivo y accesibilidad completa
- Validación cruzada con .refine(): victorias no puede superar partidas; el error sale en el campo 'victorias'
- Winrate en vivo con watch('partidas') y watch('victorias') mientras el usuario escribe
- aria-invalid en los inputs y aria-describedby enlazado con el id del mensaje de error
- La UI aguanta a 375px sin romperse: flex-wrap en la lista, texto no desbordado, formulario legible
Ver soluciones
import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
interface Heroe {
id: number;
nombre: string;
rol: "Daño" | "Apoyo" | "Tanque";
partidas: number;
victorias: number;
}
const ROSTER_INICIAL: Heroe[] = [
{ id: 1, nombre: "Tracer", rol: "Daño", partidas: 120, victorias: 78 },
{ id: 2, nombre: "Mercy", rol: "Apoyo", partidas: 200, victorias: 150 },
{ id: 3, nombre: "Reinhardt", rol: "Tanque", partidas: 95, victorias: 52 },
];
// El schema es la única fuente de verdad: valida en runtime Y deriva el tipo TypeScript.
// Si cambias una regla aquí, el tipo y la validación cambian a la vez, sin desincronizarse.
const schema = z.object({
// nombre: mínimo 2 caracteres.
nombre: z.string().min(2, "El nombre debe tener al menos 2 caracteres"),
// rol: conjunto cerrado de valores. Con z.union de literales obligamos a que el rol
// sea exactamente uno de estos tres strings.
rol: z.union([z.literal("Daño"), z.literal("Apoyo"), z.literal("Tanque")], {
// El segundo argumento de z.union personaliza el mensaje si el valor no encaja en ningún literal.
error: "Elige un rol válido",
}),
// partidas: número entero de al menos 1.
// valueAsNumber en register convierte el string del input a number antes de validar.
partidas: z.number({ error: "Escribe un número" }).int("Debe ser entero").min(1, "Al menos 1 partida"),
// victorias: número entero de al menos 0.
victorias: z.number({ error: "Escribe un número" }).int("Debe ser entero").min(0, "No puede ser negativo"),
});
// z.infer extrae el tipo TypeScript equivalente al schema.
// Si cambias el schema, este tipo se actualiza solo: una sola fuente de verdad.
type DatosFormulario = z.infer<typeof schema>;
let proximoId = 4;
export default function App() {
const [roster, setRoster] = useState<Heroe[]>(ROSTER_INICIAL);
// useForm<DatosFormulario> tipado con el genérico: register y errors conocen los campos.
// zodResolver conecta el schema con RHF: en cada submit, RHF pasa los datos por Zod.
const { register, handleSubmit, formState: { errors } } = useForm<DatosFormulario>({
resolver: zodResolver(schema),
});
function onSubmit(datos: DatosFormulario) {
// Aquí los datos ya pasaron Zod: nombre es string, partidas/victorias son numbers.
// Creamos el héroe nuevo y lo añadimos al roster con setRoster inmutable.
setRoster((prev) => [...prev, { id: proximoId++, ...datos }]);
}
return (
<section>
<h1 className="titulo">Team Builder — Añadir héroe</h1>
{/* handleSubmit hace preventDefault, valida con Zod y llama a onSubmit solo si pasa. */}
<form className="formulario" onSubmit={handleSubmit(onSubmit)}>
<div className="campo">
<label htmlFor="nombre">Nombre</label>
{/* register conecta el input con RHF sin hacer el input controlado (sin value/onChange). */}
{/* RHF lo gestiona con una ref interna: teclear NO re-renderiza el componente. */}
<input id="nombre" type="text" {...register("nombre")} placeholder="Nombre del héroe" />
{/* errors.nombre?.message existe solo si Zod rechazó el campo. */}
{errors.nombre && <span className="error-msg">{errors.nombre.message}</span>}
</div>
<div className="campo">
<label htmlFor="rol">Rol</label>
{/* Un <select> nativo funciona con register igual que un <input>. */}
<select id="rol" {...register("rol")}>
<option value="">Elige un rol</option>
<option value="Daño">Daño</option>
<option value="Apoyo">Apoyo</option>
<option value="Tanque">Tanque</option>
</select>
{errors.rol && <span className="error-msg">{errors.rol.message}</span>}
</div>
<div className="campo">
<label htmlFor="partidas">Partidas jugadas</label>
{/* valueAsNumber: true le dice a RHF que convierta el string del input a number. */}
{/* Sin esto, z.number() recibe "5" (string) y la validación falla aunque parezca un número. */}
<input id="partidas" type="number" min={1} {...register("partidas", { valueAsNumber: true })} placeholder="0" />
{errors.partidas && <span className="error-msg">{errors.partidas.message}</span>}
</div>
<div className="campo">
<label htmlFor="victorias">Victorias</label>
<input id="victorias" type="number" min={0} {...register("victorias", { valueAsNumber: true })} placeholder="0" />
{errors.victorias && <span className="error-msg">{errors.victorias.message}</span>}
</div>
<button type="submit" className="boton">Añadir héroe</button>
</form>
<div className="lista">
{roster.length === 0 ? (
<p className="vacio">El roster está vacío. Añade un héroe.</p>
) : (
roster.map((heroe) => {
// El winrate es un dato derivado: se calcula en el render, no se guarda en estado.
const winrate = Math.round((heroe.victorias / heroe.partidas) * 100);
return (
<article key={heroe.id} className="tarjeta">
<h2 className="tarjeta__nombre">{heroe.nombre}</h2>
<p className="tarjeta__rol">{heroe.rol}</p>
<p className="tarjeta__stats">{winrate}% · {heroe.partidas} partidas</p>
</article>
);
})
)}
</div>
</section>
);
} Por qué este nivel
- El patrón sin RHF sería: un useState por campo, un onChange por input, un preventDefault a mano y validaciones con if. Con 4 campos son 12 líneas de fontanería antes de escribir una sola regla de negocio. useForm con zodResolver las elimina todas: register conecta el input, handleSubmit hace el preventDefault y llama a onSubmit solo si Zod acepta, y errors tiene los mensajes listos para mostrar.
- valueAsNumber: true es el detalle que más se olvida. Un <input type='number'> siempre entrega un string al DOM. Sin valueAsNumber, z.number() recibe '5' (string) y el formulario no envía sin decirte por qué: un fallo silencioso clásico. Con valueAsNumber, RHF convierte el string a number antes de pasárselo a Zod y todo encaja.
import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
interface Heroe {
id: number;
nombre: string;
rol: "Daño" | "Apoyo" | "Tanque";
partidas: number;
victorias: number;
}
const ROSTER_INICIAL: Heroe[] = [
{ id: 1, nombre: "Tracer", rol: "Daño", partidas: 120, victorias: 78 },
{ id: 2, nombre: "Mercy", rol: "Apoyo", partidas: 200, victorias: 150 },
{ id: 3, nombre: "Reinhardt", rol: "Tanque", partidas: 95, victorias: 52 },
];
const schema = z.object({
nombre: z.string().min(2, "El nombre debe tener al menos 2 caracteres").max(40, "Máximo 40 caracteres"),
rol: z.union([z.literal("Daño"), z.literal("Apoyo"), z.literal("Tanque")], {
error: "Elige un rol válido",
}),
partidas: z.number({ error: "Escribe un número" }).int("Debe ser entero").min(1, "Al menos 1 partida"),
victorias: z.number({ error: "Escribe un número" }).int("Debe ser entero").min(0, "No puede ser negativo"),
});
type DatosFormulario = z.infer<typeof schema>;
let proximoId = 4;
export default function App() {
const [roster, setRoster] = useState<Heroe[]>(ROSTER_INICIAL);
const {
register,
handleSubmit,
// reset() devuelve el formulario a los defaultValues tras un envío exitoso.
reset,
formState: { errors, isSubmitting },
} = useForm<DatosFormulario>({
resolver: zodResolver(schema),
// defaultValues evita que los inputs empiecen como "uncontrolled" (sin valor inicial).
// RHF necesita saber el valor inicial para comparar si el formulario ha cambiado (isDirty).
defaultValues: {
nombre: "",
rol: undefined,
partidas: undefined,
victorias: undefined,
},
});
function onSubmit(datos: DatosFormulario) {
setRoster((prev) => [
...prev,
{
id: proximoId++,
nombre: datos.nombre,
rol: datos.rol,
partidas: datos.partidas,
victorias: datos.victorias,
},
]);
// Después de añadir el héroe, vaciamos el formulario para el siguiente.
// reset() con defaultValues lo deja como al principio, incluyendo los valores numéricos.
reset();
}
return (
<section>
<h1 className="titulo">Team Builder — Añadir héroe</h1>
<form className="formulario" onSubmit={handleSubmit(onSubmit)}>
<div className="campo">
{/* htmlFor + id en cada campo: el lector de pantalla anuncia el label al enfocar el input. */}
<label htmlFor="nombre">Nombre</label>
<input id="nombre" type="text" {...register("nombre")} placeholder="Nombre del héroe" />
{errors.nombre && <span className="error-msg" role="alert">{errors.nombre.message}</span>}
</div>
<div className="campo">
<label htmlFor="rol">Rol</label>
<select id="rol" {...register("rol")}>
<option value="">Elige un rol</option>
<option value="Daño">Daño</option>
<option value="Apoyo">Apoyo</option>
<option value="Tanque">Tanque</option>
</select>
{errors.rol && <span className="error-msg" role="alert">{errors.rol.message}</span>}
</div>
<div className="campo">
<label htmlFor="partidas">Partidas jugadas</label>
<input id="partidas" type="number" min={1} {...register("partidas", { valueAsNumber: true })} placeholder="0" />
{errors.partidas && <span className="error-msg" role="alert">{errors.partidas.message}</span>}
</div>
<div className="campo">
<label htmlFor="victorias">Victorias</label>
<input id="victorias" type="number" min={0} {...register("victorias", { valueAsNumber: true })} placeholder="0" />
{errors.victorias && <span className="error-msg" role="alert">{errors.victorias.message}</span>}
</div>
{/* isSubmitting es true mientras handleSubmit espera a que onSubmit resuelva. */}
{/* Deshabilitar el botón durante ese tiempo evita envíos dobles. */}
<button type="submit" className="boton" disabled={isSubmitting}>
{isSubmitting ? "Añadiendo..." : "Añadir héroe"}
</button>
</form>
<div className="lista">
{roster.length === 0 ? (
<p className="vacio">El roster está vacío. Añade un héroe.</p>
) : (
roster.map((heroe) => {
// El winrate es un dato derivado: se calcula en el render, no se guarda en estado.
const winrate = Math.round((heroe.victorias / heroe.partidas) * 100);
return (
<article key={heroe.id} className="tarjeta">
<h2 className="tarjeta__nombre">{heroe.nombre}</h2>
<p className="tarjeta__rol">{heroe.rol}</p>
<p className="tarjeta__stats">{winrate}% · {heroe.partidas} partidas</p>
</article>
);
})
)}
</div>
</section>
);
} Por qué es mejor que el anterior
- defaultValues cumple dos funciones. La primera, práctica: RHF necesita saber el valor inicial de cada campo para calcular isDirty (si el formulario ha cambiado desde que se abrió) y para saber a qué estado volver al llamar a reset(). Sin defaultValues, reset() deja los campos en undefined y pueden quedar visualmente raros. La segunda, de accesibilidad: un campo sin valor inicial puede saltar entre 'sin controlar' y 'controlado' y producir advertencias en consola.
- isSubmitting es true durante el tiempo que handleSubmit espera a que tu función onSubmit resuelva su promesa. En este ejercicio onSubmit es síncrono así que el tiempo es cero, pero en una app real haría una petición a la API y podría tardar un segundo. Deshabilitar el botón durante ese tiempo evita que el usuario pulse dos veces y añada el mismo héroe dos veces. El patrón es siempre el mismo: disabled={isSubmitting}.
import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
interface Heroe {
id: number;
nombre: string;
rol: "Daño" | "Apoyo" | "Tanque";
partidas: number;
victorias: number;
}
const ROSTER_INICIAL: Heroe[] = [
{ id: 1, nombre: "Tracer", rol: "Daño", partidas: 120, victorias: 78 },
{ id: 2, nombre: "Mercy", rol: "Apoyo", partidas: 200, victorias: 150 },
{ id: 3, nombre: "Reinhardt", rol: "Tanque", partidas: 95, victorias: 52 },
];
// .refine() es validación cruzada: ve el objeto completo (dos campos a la vez).
// Un validador de campo solo ve su propio campo; "victorias <= partidas" necesita los dos.
// path: ["victorias"] cuelga el error del campo correcto, no del objeto entero.
const schema = z
.object({
nombre: z.string().min(2, "El nombre debe tener al menos 2 caracteres").max(40, "Máximo 40 caracteres"),
rol: z.union([z.literal("Daño"), z.literal("Apoyo"), z.literal("Tanque")], {
error: "Elige un rol válido",
}),
partidas: z.number({ error: "Escribe un número" }).int("Debe ser entero").min(1, "Al menos 1 partida"),
victorias: z.number({ error: "Escribe un número" }).int("Debe ser entero").min(0, "No puede ser negativo"),
})
.refine((d) => d.victorias <= d.partidas, {
message: "Las victorias no pueden superar las partidas jugadas",
path: ["victorias"],
});
type DatosFormulario = z.infer<typeof schema>;
let proximoId = 4;
export default function App() {
const [roster, setRoster] = useState<Heroe[]>(ROSTER_INICIAL);
const {
register,
handleSubmit,
reset,
// watch suscribe el componente al valor en vivo de un campo.
// Úsalo con criterio: watch() sin argumento re-renderiza en cada tecla de cualquier campo.
// watch("victorias") y watch("partidas") solo re-renderizan cuando esos dos campos cambian.
watch,
formState: { errors, isSubmitting },
} = useForm<DatosFormulario>({
resolver: zodResolver(schema),
defaultValues: {
nombre: "",
rol: undefined,
partidas: undefined,
victorias: undefined,
},
});
// Leemos los valores en vivo de partidas y victorias para calcular el winrate al vuelo.
const partidas = watch("partidas");
const victorias = watch("victorias");
// El winrate en vivo: solo se calcula si hay valores numéricos válidos (no NaN, no negativo).
const winrateEnVivo =
partidas > 0 && victorias >= 0 && victorias <= partidas
? Math.round((victorias / partidas) * 100)
: null;
function onSubmit(datos: DatosFormulario) {
setRoster((prev) => [
...prev,
{
id: proximoId++,
nombre: datos.nombre,
rol: datos.rol,
partidas: datos.partidas,
victorias: datos.victorias,
},
]);
reset();
}
return (
<section>
<h1 className="titulo">Team Builder — Añadir héroe</h1>
<form className="formulario" onSubmit={handleSubmit(onSubmit)}>
<div className="campo">
<label htmlFor="nombre">Nombre</label>
{/* aria-invalid informa a la tecnología asistiva de que el campo tiene un error. */}
<input
id="nombre"
type="text"
aria-invalid={errors.nombre ? "true" : "false"}
aria-describedby={errors.nombre ? "error-nombre" : undefined}
{...register("nombre")}
placeholder="Nombre del héroe"
/>
{errors.nombre && (
// id + aria-describedby conectan el input con su mensaje de error para el lector de pantalla.
<span id="error-nombre" className="error-msg" role="alert">
{errors.nombre.message}
</span>
)}
</div>
<div className="campo">
<label htmlFor="rol">Rol</label>
<select
id="rol"
aria-invalid={errors.rol ? "true" : "false"}
aria-describedby={errors.rol ? "error-rol" : undefined}
{...register("rol")}
>
<option value="">Elige un rol</option>
<option value="Daño">Daño</option>
<option value="Apoyo">Apoyo</option>
<option value="Tanque">Tanque</option>
</select>
{errors.rol && (
<span id="error-rol" className="error-msg" role="alert">
{errors.rol.message}
</span>
)}
</div>
<div className="campo">
<label htmlFor="partidas">Partidas jugadas</label>
<input
id="partidas"
type="number"
min={1}
aria-invalid={errors.partidas ? "true" : "false"}
aria-describedby={errors.partidas ? "error-partidas" : undefined}
{...register("partidas", { valueAsNumber: true })}
placeholder="0"
/>
{errors.partidas && (
<span id="error-partidas" className="error-msg" role="alert">
{errors.partidas.message}
</span>
)}
</div>
<div className="campo">
<label htmlFor="victorias">
Victorias
{/* El winrate en vivo aparece junto al label mientras el usuario escribe. */}
{winrateEnVivo !== null && (
<span className="winrate-vivo"> — {winrateEnVivo}% winrate</span>
)}
</label>
<input
id="victorias"
type="number"
min={0}
aria-invalid={errors.victorias ? "true" : "false"}
aria-describedby={errors.victorias ? "error-victorias" : undefined}
{...register("victorias", { valueAsNumber: true })}
placeholder="0"
/>
{/* Este error puede venir del .refine() de validación cruzada o del campo propio. */}
{errors.victorias && (
<span id="error-victorias" className="error-msg" role="alert">
{errors.victorias.message}
</span>
)}
</div>
<button type="submit" className="boton" disabled={isSubmitting}>
{isSubmitting ? "Añadiendo..." : "Añadir héroe"}
</button>
</form>
<div className="lista">
{roster.length === 0 ? (
<p className="vacio">El roster está vacío. Añade un héroe.</p>
) : (
roster.map((heroe) => {
const winrate = Math.round((heroe.victorias / heroe.partidas) * 100);
return (
<article key={heroe.id} className="tarjeta">
<h2 className="tarjeta__nombre">{heroe.nombre}</h2>
<p className="tarjeta__rol">{heroe.rol}</p>
<p className="tarjeta__stats">{winrate}% · {heroe.partidas} partidas</p>
</article>
);
})
)}
</div>
</section>
);
} Por qué es mejor que el anterior
- .refine() es necesario porque 'victorias <= partidas' es una condición entre dos campos. Zod valida los campos de un objeto en paralelo: en el momento en que valida el campo 'victorias', aún no sabe el valor de 'partidas'. Solo con el objeto ya construido (después de validar todos los campos individualmente) puede hacer la comparación. Por eso .refine() opera sobre el objeto completo y no sobre un campo. El argumento path: ['victorias'] le dice a RHF dónde colgar el error en el formulario; sin path, errors.victorias sería undefined y el error no aparecería en ningún sitio.
- watch() suscribe el componente al valor en vivo de uno o varios campos. watch() sin argumento se suscribe a todos los campos y re-renderiza en cada tecla de cualquiera de ellos, lo que anularía la ventaja de rendimiento de RHF. watch('partidas') y watch('victorias') solo re-renderizan cuando esos dos campos cambian, que es exactamente lo necesario para calcular el winrate en vivo. Es un uso válido de watch porque la reactividad tiene un propósito claro para el usuario.
- aria-invalid + aria-describedby es el par mínimo de accesibilidad para un campo con error. aria-invalid='true' informa al lector de pantalla de que el campo está en error cuando lo enfoca. aria-describedby apunta al id del mensaje de error, así que cuando el usuario llega al campo, el lector lee primero el label, luego el tipo de campo, y luego el mensaje de error. Sin esto, un usuario de lector de pantalla que navega por Tab no sabe qué campo tiene el error ni cuál es.