Has llegado al final del nivel. No hay concepto nuevo: este capítulo es la prueba de que puedes coger todo lo que has aprendido —componentes, props, estado, efectos, hooks, context, routing, data-fetching, formularios y patrones— y montar con ello una app completa, de principio a fin.
Qué construyes#
El Team Builder, pero esta vez como una aplicación React real, no un ejercicio aislado. Una SPA con dos pantallas:
- Roster (
/): carga los héroes del servidor, los filtra por rol y los añade a tu equipo. - Mi equipo (
/equipo): muestra el equipo seleccionado y un formulario para añadir un héroe propio.
Y en el tier excelente, una tercera: la ficha de un héroe (/heroe/:id).
Lo construyes desde cero con Vite (lo viste en el Nivel 4), porque ya has hecho cada pieza por separado a lo largo del nivel. Aquí solo las juntas.
La arquitectura#
Separa responsabilidades en ficheros con un papel claro, en vez de amontonar todo en App.tsx:
dominio.ts— el contrato de datos: el schema Zod del héroe y el tipo derivado conz.infer, más el cálculo delwinrate.api.ts— la “red”: una función que devuelve los héroes en una promesa (como haría unfetch).store.ts(oEquipoContext.tsxen el tier ok) — el estado del equipo, compartido entre rutas.- Páginas (
RosterPage,EquipoPage,HeroePage) — una por ruta. - Componentes (
Tabs) — piezas de UI reutilizables. useHeroes.ts(tier excelente) — la lógica de datos extraída a un custom hook.App.tsx— el layout y las rutas.main.tsx— el punto de entrada y los providers.
El plan de ataque#
No intentes escribirlo todo de golpe. Ve capa por capa, y arranca con la versión más simple (tier ok) antes de subir.
1. El dominio y los datos#
Empieza por lo que no depende de React. El schema Zod es la fuente de verdad del tipo:
// dominio.ts — el tipo se deriva del schema (capítulo de formularios y Nivel 5).
export const RolSchema = z.enum(["Daño", "Apoyo", "Tanque"]);
export const HeroeSchema = z.object({
id: z.number(),
nombre: z.string(),
rol: RolSchema,
partidas: z.number(),
victorias: z.number(),
});
export type Heroe = z.infer<typeof HeroeSchema>;Y api.ts simula la red con una promesa, como en el capítulo de data-fetching.
2. Las rutas y los providers#
App define el layout y las rutas; main monta los providers. El orden de los providers importa:
// main.tsx (tier mejor): Query para los datos, Router para las rutas.
createRoot(document.getElementById("root")!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
</StrictMode>,
);3. Los datos: de useEffect a useQuery#
En el tier ok, cargas con useEffect + useState (capítulo de hooks fundamentales). En el tier mejor, lo cambias por useQuery (capítulo de data-fetching), que te da caché y estados de carga/error sin escribirlos a mano:
// tier mejor: una línea sustituye al useEffect + dos useState.
const { data: heroes = [], isLoading, isError } = useQuery({
queryKey: ["heroes"],
queryFn: cargarHeroes,
});4. El estado del equipo: de context a Zustand#
El equipo lo modifica el Roster y lo lee la página de Equipo: es estado compartido entre rutas. En el tier ok vive en un context (capítulo de custom hooks); en el tier mejor sube a un store de Zustand (capítulo de gestión de estado), que no necesita Provider:
// store.ts (tier mejor): create define el estado y las acciones.
export const useEquipo = create<EquipoStore>((set) => ({
equipo: [],
anadir: (heroe) => set((estado) => ({ equipo: [...estado.equipo, heroe] })),
quitar: (id) => set((estado) => ({ equipo: estado.equipo.filter((h) => h.id !== id) })),
}));5. El filtro: de botones a un componente compuesto#
En el tier ok, el filtro por rol son botones con un useState. En el tier mejor, lo conviertes en un componente compuesto <Tabs> (capítulo anterior): el contenedor guarda la pestaña activa por context y las piezas la leen.
6. El formulario#
El formulario de añadir héroe es react-hook-form + Zod (capítulo de formularios), con valueAsNumber en los campos numéricos. Su schema solo tiene los campos que el usuario rellena; el id lo generas tú al añadir:
// El id no lo teclea el usuario: lo añade la app al componer el héroe.
function onSubmit(datos: DatosForm) {
const heroe: Heroe = { id: proximoId++, ...datos };
anadir(heroe);
}7. El pulido (tier excelente)#
Cuando funcione, sube: extrae la consulta a un custom hook useHeroes(), añade la ruta de detalle /heroe/:id con useParams, haz el formulario accesible con aria-invalid/aria-describedby, muestra un dato derivado (el winrate medio del equipo) y comprueba que todo aguanta a 375px.
Comprueba lo que sabes#
Pregunta 1 de 4
El equipo seleccionado lo modifica la página del Roster y lo lee la de Equipo, en rutas distintas. ¿Por qué en el tier 'mejor' se sube ese estado a un store de Zustand en vez de dejarlo en un context?
Tu turno#
Este proyecto se monta en local, con un proyecto Vite real. Sigue el README de la carpeta del ejercicio: crea el proyecto, instala las dependencias del nivel y construye la app capa por capa, empezando por el tier ok. Cuando un tier funcione, compáralo con su solución de referencia antes de subir al siguiente.
Ejercicio · hazlo en local
Team Builder en React + TypeScript
Reconstruye el Team Builder como una app React real. Monta un proyecto Vite con React y TypeScript, instala las dependencias del nivel (react-router, zustand, @tanstack/react-query, react-hook-form, @hookform/resolvers, zod) y construye la app siguiendo el plan de ataque. Dos rutas: el Roster (cargar héroes, filtrar por rol, añadir al equipo) y Mi equipo (ver el equipo y añadir un héroe propio con un formulario validado). Sube de tier según las herramientas que uses y el pulido que apliques.
Paso 1: La app funciona, con las herramientas más simples
- Dos rutas con react-router (Roster y Equipo) y navegación con NavLink que marca el enlace activo.
- El roster se carga del 'servidor' con useEffect + useState y muestra un estado de carga.
- El estado del equipo (compartido entre rutas) vive en un context, con un hook guardián que falla si se usa fuera del provider.
- El filtro por rol funciona (botones) y añadir respeta las reglas: máximo 6, sin duplicados.
- El formulario de añadir héroe usa react-hook-form + Zod con valueAsNumber en los campos numéricos.
Paso 2: Las herramientas adecuadas para producción
- Los datos se cargan con TanStack Query (useQuery): caché, isLoading e isError gestionados, sin useEffect a mano.
- El estado del equipo vive en un store de Zustand con selectores; ya no hay EquipoProvider en main.
- El filtro por rol es un componente compuesto <Tabs> (contenedor con estado por context + piezas Tab/TabPanel).
- El formulario es completo: defaultValues, reset() tras añadir e isSubmitting deshabilitando el botón.
Paso 3: Arquitectura, accesibilidad y detalle
- La consulta está extraída a un custom hook useHeroes(); las páginas no llaman a useQuery directamente.
- Una tercera ruta /heroe/:id con useParams muestra la ficha del héroe (reutilizando la caché de useHeroes), con vuelta mediante useNavigate.
- El formulario es accesible: aria-invalid y aria-describedby enlazando cada input con su mensaje de error.
- Un dato derivado en pantalla: el winrate medio del equipo, calculado en el render.
- La UI aguanta a 375px sin romperse.
Cómo hacerlo en local
Clona el repositorio del curso, entra en la carpeta del ejercicio y abre el
index.html en tu navegador. Toda tu solución va en
solucion.js.
git clone <repo>
cd exercises/nivel-6/proyecto-team-builder-react
# abre index.html en el navegador y edita solucion.js Ver soluciones
// ── dominio.ts ──────────────────────────────────────────────
import { z } from "zod";
// El esquema Zod es la fuente de verdad del dato Heroe: valida en runtime Y deriva el tipo.
// z.enum cierra el conjunto de roles válidos (en proyecto real, no en el playground).
export const RolSchema = z.enum(["Daño", "Apoyo", "Tanque"]);
// HeroeSchema describe la forma completa de un héroe.
export const HeroeSchema = z.object({
id: z.number(),
nombre: z.string(),
rol: RolSchema,
partidas: z.number(),
victorias: z.number(),
});
// El tipo Heroe se deriva del esquema: una sola fuente de verdad (si cambia el schema, cambia el tipo).
export type Heroe = z.infer<typeof HeroeSchema>;
// El tipo del rol, reutilizable en filtros y formularios.
export type Rol = z.infer<typeof RolSchema>;
// El winrate es un dato derivado: se calcula donde se necesita, no se guarda.
export function winrate(heroe: Heroe): number {
// Evitamos dividir por cero si un héroe no tiene partidas.
if (heroe.partidas === 0) {
return 0;
}
return Math.round((heroe.victorias / heroe.partidas) * 100);
}
// ── api.ts ──────────────────────────────────────────────────
import type { Heroe } from "./dominio";
// El roster que "vive en el servidor". En una app real esto sería una base de datos.
const ROSTER: Heroe[] = [
{ id: 1, nombre: "Tracer", rol: "Daño", partidas: 120, victorias: 78 },
{ id: 2, nombre: "Genji", rol: "Daño", partidas: 140, victorias: 79 },
{ id: 3, nombre: "Mercy", rol: "Apoyo", partidas: 200, victorias: 150 },
{ id: 4, nombre: "Ana", rol: "Apoyo", partidas: 88, victorias: 61 },
{ id: 5, nombre: "Reinhardt", rol: "Tanque", partidas: 95, victorias: 52 },
{ id: 6, nombre: "Sigma", rol: "Tanque", partidas: 70, victorias: 41 },
];
// Simula una llamada de red: devuelve una promesa que resuelve tras 600ms,
// como haría un fetch real. El componente la trata como una petición asíncrona.
export function cargarHeroes(): Promise<Heroe[]> {
return new Promise((resolve) => {
setTimeout(() => resolve(ROSTER), 600);
});
}
// ── EquipoContext.tsx ───────────────────────────────────────
import { createContext, useContext, useState } from "react";
import type { ReactNode } from "react";
import type { Heroe } from "./dominio";
// El equipo seleccionado es estado COMPARTIDO entre páginas (Roster lo modifica, Equipo lo lee).
// En el tier OK lo guardamos en un context: un canal directo por el árbol, sin prop drilling.
interface EquipoContexto {
equipo: Heroe[];
anadir: (heroe: Heroe) => void;
quitar: (id: number) => void;
estaEnEquipo: (id: number) => boolean;
}
// Valor por defecto null: detectamos el uso fuera del provider.
const EquipoContext = createContext<EquipoContexto | null>(null);
// Hook guardián: lee el context y falla con un error claro si no hay provider encima.
export function useEquipo(): EquipoContexto {
const ctx = useContext(EquipoContext);
if (ctx === null) {
throw new Error("useEquipo debe usarse dentro de <EquipoProvider>");
}
return ctx;
}
// El provider guarda el estado del equipo y expone las operaciones para modificarlo.
export function EquipoProvider({ children }: { children: ReactNode }) {
const [equipo, setEquipo] = useState<Heroe[]>([]);
// anadir respeta las reglas de negocio: máximo 6 héroes y sin duplicados.
function anadir(heroe: Heroe) {
setEquipo((prev) => {
// Si ya hay 6, no añadimos: devolvemos el mismo array sin cambios.
if (prev.length >= 6) {
return prev;
}
// Si el héroe ya está, tampoco: evita duplicados.
if (prev.some((h) => h.id === heroe.id)) {
return prev;
}
// Añadimos de forma inmutable: un array nuevo, no mutamos el anterior.
return [...prev, heroe];
});
}
// quitar elimina el héroe por id, también de forma inmutable (filter crea un array nuevo).
function quitar(id: number) {
setEquipo((prev) => prev.filter((h) => h.id !== id));
}
// estaEnEquipo es un dato derivado: lo calculamos al vuelo, no lo guardamos.
function estaEnEquipo(id: number): boolean {
return equipo.some((h) => h.id === id);
}
return (
<EquipoContext.Provider value={{ equipo, anadir, quitar, estaEnEquipo }}>
{children}
</EquipoContext.Provider>
);
}
// ── RosterPage.tsx ──────────────────────────────────────────
import { useEffect, useState } from "react";
import type { Heroe, Rol } from "./dominio";
import { winrate } from "./dominio";
import { cargarHeroes } from "./api";
import { useEquipo } from "./EquipoContext";
// Los filtros disponibles: "Todos" más los tres roles.
const FILTROS: (Rol | "Todos")[] = ["Todos", "Daño", "Apoyo", "Tanque"];
export function RosterPage() {
// Estado de la carga: los héroes que llegan del servidor y si seguimos cargando.
const [heroes, setHeroes] = useState<Heroe[]>([]);
const [cargando, setCargando] = useState(true);
// Estado de UI local: el rol por el que filtramos.
const [filtro, setFiltro] = useState<Rol | "Todos">("Todos");
// Operaciones del equipo, leídas del context compartido.
const { anadir, estaEnEquipo } = useEquipo();
// useEffect carga los héroes al montar la página (array de deps vacío: solo una vez).
useEffect(() => {
cargarHeroes().then((datos) => {
// Cuando la promesa resuelve, guardamos los datos y apagamos el "cargando".
setHeroes(datos);
setCargando(false);
});
}, []);
// Mientras la promesa no resuelve, mostramos un aviso de carga.
if (cargando) {
return <p className="cargando">Cargando roster...</p>;
}
// El filtrado es un dato derivado: se calcula en el render a partir de heroes y filtro.
const visibles = filtro === "Todos" ? heroes : heroes.filter((h) => h.rol === filtro);
return (
<section>
<div className="filtros">
{FILTROS.map((rol) => (
<button
key={rol}
// El botón activo lleva la clase principal; el resto, la secundaria.
className={filtro === rol ? "boton" : "boton boton--secundario"}
onClick={() => setFiltro(rol)}
>
{rol}
</button>
))}
</div>
<div className="lista">
{visibles.map((h) => {
// estaEnEquipo decide si el héroe ya está añadido (para bloquear el botón).
const yaEsta = estaEnEquipo(h.id);
return (
<article key={h.id} className="tarjeta">
<h2 className="tarjeta__nombre">{h.nombre}</h2>
<p className="tarjeta__rol">{h.rol}</p>
{/* winrate es un dato derivado, calculado en dominio.ts. */}
<p className="tarjeta__stats">{winrate(h)}% winrate</p>
<button
className="boton"
// Si ya está en el equipo, el botón se desactiva.
disabled={yaEsta}
onClick={() => anadir(h)}
>
{yaEsta ? "En el equipo" : "Añadir"}
</button>
</article>
);
})}
</div>
</section>
);
}
// ── EquipoPage.tsx ──────────────────────────────────────────
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { RolSchema, winrate } from "./dominio";
import type { Heroe } from "./dominio";
import { useEquipo } from "./EquipoContext";
// El formulario pide solo lo que el usuario rellena; el id lo generamos nosotros al añadir.
const FormSchema = z.object({
nombre: z.string().min(2, "El nombre debe tener al menos 2 caracteres"),
rol: RolSchema,
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"),
});
// El tipo del formulario se deriva del schema: una sola fuente de verdad.
type DatosForm = z.infer<typeof FormSchema>;
// Contador para ids de héroes creados por el usuario (no chocan con los del servidor).
let proximoId = 100;
export function EquipoPage() {
// Estado del equipo, leído del context compartido.
const { equipo, quitar, anadir } = useEquipo();
// useForm tipado con DatosForm y validado con el schema vía zodResolver.
const {
register,
handleSubmit,
formState: { errors },
} = useForm<DatosForm>({
resolver: zodResolver(FormSchema),
});
// onSubmit solo se llama si Zod acepta los datos: aquí ya son seguros.
function onSubmit(datos: DatosForm) {
// Componemos el héroe con un id generado y lo añadimos al equipo.
const heroe: Heroe = { id: proximoId++, ...datos };
anadir(heroe);
}
return (
<section>
<h2 className="seccion__titulo">Mi equipo ({equipo.length} / 6)</h2>
{equipo.length === 0 ? (
<p className="vacio">El equipo está vacío. Añade héroes desde el roster.</p>
) : (
<div className="lista">
{equipo.map((h) => (
<article key={h.id} className="tarjeta">
<h3 className="tarjeta__nombre">{h.nombre}</h3>
<p className="tarjeta__rol">{h.rol}</p>
<p className="tarjeta__stats">{winrate(h)}% winrate</p>
<button className="boton boton--secundario" onClick={() => quitar(h.id)}>
Quitar
</button>
</article>
))}
</div>
)}
<h2 className="seccion__titulo">Añadir un héroe propio</h2>
{/* 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>
<input id="nombre" type="text" {...register("nombre")} placeholder="Nombre del héroe" />
{errors.nombre && <span className="error-msg">{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">{errors.rol.message}</span>}
</div>
<div className="campo">
<label htmlFor="partidas">Partidas</label>
{/* valueAsNumber convierte el string del input a number antes de validar. */}
<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 al equipo
</button>
</form>
</section>
);
}
// ── App.tsx ─────────────────────────────────────────────────
import { Routes, Route, NavLink } from "react-router";
import { RosterPage } from "./RosterPage";
import { EquipoPage } from "./EquipoPage";
// App define el layout común (cabecera + navegación) y las rutas de la SPA.
export function App() {
return (
<div className="app">
<header className="cabecera">
<h1 className="titulo">Team Builder</h1>
<nav className="nav">
{/* NavLink marca el enlace activo según la ruta actual (con aria-current). */}
{/* end evita que "/" salga activo también en "/equipo". */}
<NavLink to="/" end className="nav__link">
Roster
</NavLink>
<NavLink to="/equipo" className="nav__link">
Mi equipo
</NavLink>
</nav>
</header>
<main>
{/* Routes pinta el primer Route cuyo path coincide con la URL. */}
<Routes>
<Route path="/" element={<RosterPage />} />
<Route path="/equipo" element={<EquipoPage />} />
</Routes>
</main>
</div>
);
}
// ── main.tsx ────────────────────────────────────────────────
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router";
import { App } from "./App";
import { EquipoProvider } from "./EquipoContext";
import "./styles.css";
// El punto de entrada: monta la app en el #root del index.html (lo crea Vite).
// El orden de los providers importa: BrowserRouter envuelve todo (las rutas viven dentro),
// y EquipoProvider envuelve App para que ambas páginas compartan el estado del equipo.
createRoot(document.getElementById("root")!).render(
<StrictMode>
<BrowserRouter>
<EquipoProvider>
<App />
</EquipoProvider>
</BrowserRouter>
</StrictMode>,
); Por qué este nivel
- El estado del equipo vive en un context (EquipoContext) con un hook guardián useEquipo() que lanza un error claro si se usa fuera del provider. Es la herramienta del capítulo de custom hooks: suficiente para compartir estado entre dos rutas.
- Los datos se cargan con useEffect + useState: la promesa de cargarHeroes resuelve, guardamos los héroes y apagamos el 'cargando'. Funciona, pero no hay caché: cada vez que se monta la página, vuelve a pedir.
- El formulario ya es react-hook-form + Zod con valueAsNumber: el patrón del capítulo de formularios, reutilizado tal cual.
// ── dominio.ts ──────────────────────────────────────────────
import { z } from "zod";
// El esquema Zod es la fuente de verdad del dato Heroe: valida en runtime Y deriva el tipo.
// z.enum cierra el conjunto de roles válidos (en proyecto real, no en el playground).
export const RolSchema = z.enum(["Daño", "Apoyo", "Tanque"]);
// HeroeSchema describe la forma completa de un héroe.
export const HeroeSchema = z.object({
id: z.number(),
nombre: z.string(),
rol: RolSchema,
partidas: z.number(),
victorias: z.number(),
});
// El tipo Heroe se deriva del esquema: una sola fuente de verdad (si cambia el schema, cambia el tipo).
export type Heroe = z.infer<typeof HeroeSchema>;
// El tipo del rol, reutilizable en filtros y formularios.
export type Rol = z.infer<typeof RolSchema>;
// El winrate es un dato derivado: se calcula donde se necesita, no se guarda.
export function winrate(heroe: Heroe): number {
// Evitamos dividir por cero si un héroe no tiene partidas.
if (heroe.partidas === 0) {
return 0;
}
return Math.round((heroe.victorias / heroe.partidas) * 100);
}
// ── api.ts ──────────────────────────────────────────────────
import type { Heroe } from "./dominio";
// El roster que "vive en el servidor". En una app real esto sería una base de datos.
const ROSTER: Heroe[] = [
{ id: 1, nombre: "Tracer", rol: "Daño", partidas: 120, victorias: 78 },
{ id: 2, nombre: "Genji", rol: "Daño", partidas: 140, victorias: 79 },
{ id: 3, nombre: "Mercy", rol: "Apoyo", partidas: 200, victorias: 150 },
{ id: 4, nombre: "Ana", rol: "Apoyo", partidas: 88, victorias: 61 },
{ id: 5, nombre: "Reinhardt", rol: "Tanque", partidas: 95, victorias: 52 },
{ id: 6, nombre: "Sigma", rol: "Tanque", partidas: 70, victorias: 41 },
];
// Simula una llamada de red: devuelve una promesa que resuelve tras 600ms,
// como haría un fetch real. El componente la trata como una petición asíncrona.
export function cargarHeroes(): Promise<Heroe[]> {
return new Promise((resolve) => {
setTimeout(() => resolve(ROSTER), 600);
});
}
// ── store.ts (Zustand) ──────────────────────────────────────
import { create } from "zustand";
import type { Heroe } from "./dominio";
// El equipo es estado GLOBAL: lo modifica el Roster y lo lee la página de Equipo, en rutas
// distintas. En el tier OK vivía en un context; aquí lo subimos a un store de Zustand,
// que no necesita Provider y permite que cualquier componente lo lea con un selector.
interface EquipoStore {
equipo: Heroe[];
anadir: (heroe: Heroe) => void;
quitar: (id: number) => void;
}
// create define el store: el estado inicial y las acciones que lo mutan vía set.
export const useEquipo = create<EquipoStore>((set) => ({
equipo: [],
// anadir respeta las reglas de negocio: máximo 6, sin duplicados.
anadir: (heroe) =>
set((estado) => {
// Si no cabe o ya está, devolvemos {} (un parcial vacío): Zustand no cambia nada.
if (estado.equipo.length >= 6 || estado.equipo.some((h) => h.id === heroe.id)) {
return {};
}
// Añadimos de forma inmutable: un array nuevo dentro de un objeto parcial.
return { equipo: [...estado.equipo, heroe] };
}),
// quitar elimina por id, también inmutable (filter crea un array nuevo).
quitar: (id) =>
set((estado) => ({ equipo: estado.equipo.filter((h) => h.id !== id) })),
}));
// ── Tabs.tsx (componente compuesto) ─────────────────────────
import { createContext, useContext, useState } from "react";
import type { ReactNode } from "react";
// Componente compuesto (patrón del capítulo anterior): el contenedor guarda la pestaña
// activa y la comparte por context; las piezas (Tab, TabPanel) la leen sin recibir props.
interface TabsContexto {
activa: string;
setActiva: (id: string) => void;
}
const TabsContext = createContext<TabsContexto | null>(null);
// Hook guardián: error claro si una pieza se usa fuera de <Tabs>.
function useTabs(): TabsContexto {
const ctx = useContext(TabsContext);
if (ctx === null) {
throw new Error("<Tab> y <TabPanel> deben ir dentro de <Tabs>");
}
return ctx;
}
// <Tabs>: el contenedor. Guarda la pestaña activa y la comparte con sus piezas.
export function Tabs({ inicial, children }: { inicial: string; children: ReactNode }) {
const [activa, setActiva] = useState(inicial);
return (
<TabsContext.Provider value={{ activa, setActiva }}>
<div className="tabs">{children}</div>
</TabsContext.Provider>
);
}
// <TabList>: contenedor visual de los botones de pestaña.
export function TabList({ children }: { children: ReactNode }) {
return (
<div className="tablist" role="tablist">
{children}
</div>
);
}
// <Tab id>: botón que activa su pestaña al pulsarlo.
export function Tab({ id, children }: { id: string; children: ReactNode }) {
const ctx = useTabs();
return (
<button
className="tab"
role="tab"
// aria-selected marca la pestaña activa (y el CSS la estila por ese atributo).
aria-selected={ctx.activa === id}
onClick={() => ctx.setActiva(id)}
>
{children}
</button>
);
}
// <TabPanel id>: muestra su contenido solo si su pestaña es la activa.
export function TabPanel({ id, children }: { id: string; children: ReactNode }) {
const ctx = useTabs();
if (ctx.activa !== id) {
return null;
}
return (
<div className="tabpanel" role="tabpanel">
{children}
</div>
);
}
// ── RosterPage.tsx ──────────────────────────────────────────
import { useQuery } from "@tanstack/react-query";
import { winrate } from "./dominio";
import type { Rol } from "./dominio";
import { cargarHeroes } from "./api";
import { useEquipo } from "./store";
import { Tabs, TabList, Tab, TabPanel } from "./Tabs";
const ROLES: Rol[] = ["Daño", "Apoyo", "Tanque"];
export function RosterPage() {
// useQuery sustituye el useEffect + useState del tier OK: caché, loading y error gratis.
// data puede ser undefined hasta que carga; con = [] lo dejamos en array vacío.
const { data: heroes = [], isLoading, isError } = useQuery({
queryKey: ["heroes"],
queryFn: cargarHeroes,
});
// Leemos del store con selectores: este componente se re-renderiza si cambia equipo o anadir.
const equipo = useEquipo((s) => s.equipo);
const anadir = useEquipo((s) => s.anadir);
if (isLoading) {
return <p className="cargando">Cargando roster...</p>;
}
if (isError) {
return <p className="error-msg">Error al cargar el roster.</p>;
}
return (
// El filtro por rol ahora es un componente compuesto <Tabs>: una pestaña por rol.
<Tabs inicial="Daño">
<TabList>
{ROLES.map((rol) => (
<Tab key={rol} id={rol}>
{rol}
</Tab>
))}
</TabList>
{ROLES.map((rol) => (
<TabPanel key={rol} id={rol}>
<div className="lista">
{heroes
.filter((h) => h.rol === rol)
.map((h) => {
// estaEnEquipo es un dato derivado del equipo seleccionado.
const yaEsta = equipo.some((e) => e.id === h.id);
return (
<article key={h.id} className="tarjeta">
<h2 className="tarjeta__nombre">{h.nombre}</h2>
<p className="tarjeta__rol">{h.rol}</p>
<p className="tarjeta__stats">{winrate(h)}% winrate</p>
<button className="boton" disabled={yaEsta} onClick={() => anadir(h)}>
{yaEsta ? "En el equipo" : "Añadir"}
</button>
</article>
);
})}
</div>
</TabPanel>
))}
</Tabs>
);
}
// ── EquipoPage.tsx ──────────────────────────────────────────
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { RolSchema, winrate } from "./dominio";
import type { Heroe } from "./dominio";
import { useEquipo } from "./store";
// El formulario pide solo lo que el usuario rellena; el id lo generamos al añadir.
const FormSchema = z.object({
nombre: z.string().min(2, "El nombre debe tener al menos 2 caracteres"),
rol: RolSchema,
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 DatosForm = z.infer<typeof FormSchema>;
let proximoId = 100;
export function EquipoPage() {
// Selectores del store: solo nos suscribimos a lo que usamos.
const equipo = useEquipo((s) => s.equipo);
const quitar = useEquipo((s) => s.quitar);
const anadir = useEquipo((s) => s.anadir);
const {
register,
handleSubmit,
// reset vacía el formulario tras añadir; isSubmitting deshabilita el botón al enviar.
reset,
formState: { errors, isSubmitting },
} = useForm<DatosForm>({
resolver: zodResolver(FormSchema),
// defaultValues fija el estado inicial y el destino de reset().
defaultValues: { nombre: "", rol: undefined, partidas: undefined, victorias: undefined },
});
function onSubmit(datos: DatosForm) {
// Componemos el héroe con un id generado y lo añadimos al equipo.
const heroe: Heroe = { id: proximoId++, ...datos };
anadir(heroe);
// Tras añadir, limpiamos el formulario para el siguiente.
reset();
}
return (
<section>
<h2 className="seccion__titulo">Mi equipo ({equipo.length} / 6)</h2>
{equipo.length === 0 ? (
<p className="vacio">El equipo está vacío. Añade héroes desde el roster.</p>
) : (
<div className="lista">
{equipo.map((h) => (
<article key={h.id} className="tarjeta">
<h3 className="tarjeta__nombre">{h.nombre}</h3>
<p className="tarjeta__rol">{h.rol}</p>
<p className="tarjeta__stats">{winrate(h)}% winrate</p>
<button className="boton boton--secundario" onClick={() => quitar(h.id)}>
Quitar
</button>
</article>
))}
</div>
)}
<h2 className="seccion__titulo">Añadir un héroe propio</h2>
<form className="formulario" onSubmit={handleSubmit(onSubmit)}>
<div className="campo">
<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</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 deshabilita el botón mientras handleSubmit procesa el envío. */}
<button type="submit" className="boton" disabled={isSubmitting}>
{isSubmitting ? "Añadiendo..." : "Añadir al equipo"}
</button>
</form>
</section>
);
}
// ── App.tsx ─────────────────────────────────────────────────
import { Routes, Route, NavLink } from "react-router";
import { RosterPage } from "./RosterPage";
import { EquipoPage } from "./EquipoPage";
// App define el layout común (cabecera + navegación) y las rutas de la SPA.
export function App() {
return (
<div className="app">
<header className="cabecera">
<h1 className="titulo">Team Builder</h1>
<nav className="nav">
{/* NavLink marca el enlace activo según la ruta actual (con aria-current). */}
{/* end evita que "/" salga activo también en "/equipo". */}
<NavLink to="/" end className="nav__link">
Roster
</NavLink>
<NavLink to="/equipo" className="nav__link">
Mi equipo
</NavLink>
</nav>
</header>
<main>
{/* Routes pinta el primer Route cuyo path coincide con la URL. */}
<Routes>
<Route path="/" element={<RosterPage />} />
<Route path="/equipo" element={<EquipoPage />} />
</Routes>
</main>
</div>
);
}
// ── main.tsx ────────────────────────────────────────────────
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { App } from "./App";
import "./styles.css";
// El cliente de TanStack Query: va a nivel de módulo para no recrearse en cada render.
const queryClient = new QueryClient();
// Fíjate en que ya NO hay EquipoProvider: el estado del equipo vive en el store de Zustand,
// que no necesita Provider. Solo envolvemos con QueryClientProvider (data) y BrowserRouter (rutas).
createRoot(document.getElementById("root")!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
</StrictMode>,
); Por qué es mejor que el anterior
- useQuery sustituye al useEffect + useState: gestiona la caché, el isLoading y el isError por ti. La página de detalle del tier excelente reutilizará esa caché sin volver a pedir.
- El estado del equipo sube a un store de Zustand. Fíjate en main.tsx: desaparece el EquipoProvider. Zustand no necesita Provider, y cada componente se suscribe con un selector (useEquipo((s) => s.equipo)) solo a lo que usa.
- El filtro por rol pasa de botones a un componente compuesto <Tabs>: el contenedor guarda la pestaña activa por context y las piezas Tab/TabPanel la leen. Es el patrón del capítulo anterior, aplicado al proyecto.
// ── dominio.ts ──────────────────────────────────────────────
import { z } from "zod";
// El esquema Zod es la fuente de verdad del dato Heroe: valida en runtime Y deriva el tipo.
// z.enum cierra el conjunto de roles válidos (en proyecto real, no en el playground).
export const RolSchema = z.enum(["Daño", "Apoyo", "Tanque"]);
// HeroeSchema describe la forma completa de un héroe.
export const HeroeSchema = z.object({
id: z.number(),
nombre: z.string(),
rol: RolSchema,
partidas: z.number(),
victorias: z.number(),
});
// El tipo Heroe se deriva del esquema: una sola fuente de verdad (si cambia el schema, cambia el tipo).
export type Heroe = z.infer<typeof HeroeSchema>;
// El tipo del rol, reutilizable en filtros y formularios.
export type Rol = z.infer<typeof RolSchema>;
// El winrate es un dato derivado: se calcula donde se necesita, no se guarda.
export function winrate(heroe: Heroe): number {
// Evitamos dividir por cero si un héroe no tiene partidas.
if (heroe.partidas === 0) {
return 0;
}
return Math.round((heroe.victorias / heroe.partidas) * 100);
}
// El winrate medio del equipo: otro dato derivado, calculado a partir de la lista.
export function winrateMedio(equipo: Heroe[]): number {
// Un equipo vacío no tiene media: devolvemos 0 para no dividir por cero.
if (equipo.length === 0) {
return 0;
}
// Sumamos los winrates individuales y dividimos entre el número de héroes.
const suma = equipo.reduce((total, h) => total + winrate(h), 0);
return Math.round(suma / equipo.length);
}
// ── api.ts ──────────────────────────────────────────────────
import type { Heroe } from "./dominio";
// El roster que "vive en el servidor". En una app real esto sería una base de datos.
const ROSTER: Heroe[] = [
{ id: 1, nombre: "Tracer", rol: "Daño", partidas: 120, victorias: 78 },
{ id: 2, nombre: "Genji", rol: "Daño", partidas: 140, victorias: 79 },
{ id: 3, nombre: "Mercy", rol: "Apoyo", partidas: 200, victorias: 150 },
{ id: 4, nombre: "Ana", rol: "Apoyo", partidas: 88, victorias: 61 },
{ id: 5, nombre: "Reinhardt", rol: "Tanque", partidas: 95, victorias: 52 },
{ id: 6, nombre: "Sigma", rol: "Tanque", partidas: 70, victorias: 41 },
];
// Simula una llamada de red: devuelve una promesa que resuelve tras 600ms,
// como haría un fetch real. El componente la trata como una petición asíncrona.
export function cargarHeroes(): Promise<Heroe[]> {
return new Promise((resolve) => {
setTimeout(() => resolve(ROSTER), 600);
});
}
// ── store.ts (Zustand) ──────────────────────────────────────
import { create } from "zustand";
import type { Heroe } from "./dominio";
// El equipo es estado GLOBAL: lo modifica el Roster y lo lee la página de Equipo, en rutas
// distintas. En el tier OK vivía en un context; aquí lo subimos a un store de Zustand,
// que no necesita Provider y permite que cualquier componente lo lea con un selector.
interface EquipoStore {
equipo: Heroe[];
anadir: (heroe: Heroe) => void;
quitar: (id: number) => void;
}
// create define el store: el estado inicial y las acciones que lo mutan vía set.
export const useEquipo = create<EquipoStore>((set) => ({
equipo: [],
// anadir respeta las reglas de negocio: máximo 6, sin duplicados.
anadir: (heroe) =>
set((estado) => {
// Si no cabe o ya está, devolvemos {} (un parcial vacío): Zustand no cambia nada.
if (estado.equipo.length >= 6 || estado.equipo.some((h) => h.id === heroe.id)) {
return {};
}
// Añadimos de forma inmutable: un array nuevo dentro de un objeto parcial.
return { equipo: [...estado.equipo, heroe] };
}),
// quitar elimina por id, también inmutable (filter crea un array nuevo).
quitar: (id) =>
set((estado) => ({ equipo: estado.equipo.filter((h) => h.id !== id) })),
}));
// ── Tabs.tsx (componente compuesto) ─────────────────────────
import { createContext, useContext, useState } from "react";
import type { ReactNode } from "react";
// Componente compuesto (patrón del capítulo anterior): el contenedor guarda la pestaña
// activa y la comparte por context; las piezas (Tab, TabPanel) la leen sin recibir props.
interface TabsContexto {
activa: string;
setActiva: (id: string) => void;
}
const TabsContext = createContext<TabsContexto | null>(null);
// Hook guardián: error claro si una pieza se usa fuera de <Tabs>.
function useTabs(): TabsContexto {
const ctx = useContext(TabsContext);
if (ctx === null) {
throw new Error("<Tab> y <TabPanel> deben ir dentro de <Tabs>");
}
return ctx;
}
// <Tabs>: el contenedor. Guarda la pestaña activa y la comparte con sus piezas.
export function Tabs({ inicial, children }: { inicial: string; children: ReactNode }) {
const [activa, setActiva] = useState(inicial);
return (
<TabsContext.Provider value={{ activa, setActiva }}>
<div className="tabs">{children}</div>
</TabsContext.Provider>
);
}
// <TabList>: contenedor visual de los botones de pestaña.
export function TabList({ children }: { children: ReactNode }) {
return (
<div className="tablist" role="tablist">
{children}
</div>
);
}
// <Tab id>: botón que activa su pestaña al pulsarlo.
export function Tab({ id, children }: { id: string; children: ReactNode }) {
const ctx = useTabs();
return (
<button
className="tab"
role="tab"
// aria-selected marca la pestaña activa (y el CSS la estila por ese atributo).
aria-selected={ctx.activa === id}
onClick={() => ctx.setActiva(id)}
>
{children}
</button>
);
}
// <TabPanel id>: muestra su contenido solo si su pestaña es la activa.
export function TabPanel({ id, children }: { id: string; children: ReactNode }) {
const ctx = useTabs();
if (ctx.activa !== id) {
return null;
}
return (
<div className="tabpanel" role="tabpanel">
{children}
</div>
);
}
// ── useHeroes.ts (custom hook de datos) ─────────────────────
import { useQuery } from "@tanstack/react-query";
import { cargarHeroes } from "./api";
import type { Heroe } from "./dominio";
// Custom hook que encapsula la consulta del roster. Las páginas no llaman a useQuery
// directamente: piden useHeroes() y reciben el resultado ya con caché, loading y error.
// Si mañana cambia la fuente de datos (otra URL, otro endpoint), se cambia AQUÍ una vez
// y todas las páginas que lo usan se benefician. Es la lógica de datos extraída a un hook.
export function useHeroes() {
return useQuery<Heroe[]>({
queryKey: ["heroes"],
queryFn: cargarHeroes,
});
}
// ── RosterPage.tsx ──────────────────────────────────────────
import { Link } from "react-router";
import { useHeroes } from "./useHeroes";
import { winrate } from "./dominio";
import type { Rol } from "./dominio";
import { useEquipo } from "./store";
import { Tabs, TabList, Tab, TabPanel } from "./Tabs";
const ROLES: Rol[] = ["Daño", "Apoyo", "Tanque"];
export function RosterPage() {
// La consulta vive en un custom hook: la página no sabe que por debajo hay useQuery.
const { data: heroes = [], isLoading, isError } = useHeroes();
const equipo = useEquipo((s) => s.equipo);
const anadir = useEquipo((s) => s.anadir);
if (isLoading) {
return <p className="cargando">Cargando roster...</p>;
}
if (isError) {
return <p className="error-msg">Error al cargar el roster.</p>;
}
return (
<Tabs inicial="Daño">
<TabList>
{ROLES.map((rol) => (
<Tab key={rol} id={rol}>
{rol}
</Tab>
))}
</TabList>
{ROLES.map((rol) => (
<TabPanel key={rol} id={rol}>
<div className="lista">
{heroes
.filter((h) => h.rol === rol)
.map((h) => {
const yaEsta = equipo.some((e) => e.id === h.id);
return (
<article key={h.id} className="tarjeta">
<h2 className="tarjeta__nombre">
{/* El nombre enlaza a la ruta de detalle del héroe (/heroe/:id). */}
<Link to={"/heroe/" + h.id} className="tarjeta__enlace">
{h.nombre}
</Link>
</h2>
<p className="tarjeta__rol">{h.rol}</p>
<p className="tarjeta__stats">{winrate(h)}% winrate</p>
<button className="boton" disabled={yaEsta} onClick={() => anadir(h)}>
{yaEsta ? "En el equipo" : "Añadir"}
</button>
</article>
);
})}
</div>
</TabPanel>
))}
</Tabs>
);
}
// ── EquipoPage.tsx ──────────────────────────────────────────
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { RolSchema, winrate, winrateMedio } from "./dominio";
import type { Heroe } from "./dominio";
import { useEquipo } from "./store";
const FormSchema = z.object({
nombre: z.string().min(2, "El nombre debe tener al menos 2 caracteres"),
rol: RolSchema,
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 DatosForm = z.infer<typeof FormSchema>;
let proximoId = 100;
export function EquipoPage() {
const equipo = useEquipo((s) => s.equipo);
const quitar = useEquipo((s) => s.quitar);
const anadir = useEquipo((s) => s.anadir);
// Dato derivado: el winrate medio del equipo, calculado en el render a partir de la lista.
const medio = winrateMedio(equipo);
const {
register,
handleSubmit,
reset,
formState: { errors, isSubmitting },
} = useForm<DatosForm>({
resolver: zodResolver(FormSchema),
defaultValues: { nombre: "", rol: undefined, partidas: undefined, victorias: undefined },
});
function onSubmit(datos: DatosForm) {
const heroe: Heroe = { id: proximoId++, ...datos };
anadir(heroe);
reset();
}
return (
<section>
<h2 className="seccion__titulo">
Mi equipo ({equipo.length} / 6)
{/* El resumen solo aparece cuando hay héroes: una media de 0 no aporta nada. */}
{equipo.length > 0 && <span className="resumen"> · {medio}% winrate medio</span>}
</h2>
{equipo.length === 0 ? (
<p className="vacio">El equipo está vacío. Añade héroes desde el roster.</p>
) : (
<div className="lista">
{equipo.map((h) => (
<article key={h.id} className="tarjeta">
<h3 className="tarjeta__nombre">{h.nombre}</h3>
<p className="tarjeta__rol">{h.rol}</p>
<p className="tarjeta__stats">{winrate(h)}% winrate</p>
<button className="boton boton--secundario" onClick={() => quitar(h.id)}>
Quitar
</button>
</article>
))}
</div>
)}
<h2 className="seccion__titulo">Añadir un héroe propio</h2>
<form className="formulario" onSubmit={handleSubmit(onSubmit)}>
<div className="campo">
<label htmlFor="nombre">Nombre</label>
<input
id="nombre"
type="text"
// aria-invalid informa a la tecnología asistiva de que el campo tiene error.
aria-invalid={errors.nombre ? "true" : "false"}
// aria-describedby enlaza el input con el id de su mensaje de error.
aria-describedby={errors.nombre ? "error-nombre" : undefined}
{...register("nombre")}
placeholder="Nombre del héroe"
/>
{errors.nombre && (
<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</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</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"
/>
{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 al equipo"}
</button>
</form>
</section>
);
}
// ── HeroePage.tsx (ruta de detalle) ─────────────────────────
import { useParams, useNavigate, Link } from "react-router";
import { useHeroes } from "./useHeroes";
import { winrate } from "./dominio";
import { useEquipo } from "./store";
export function HeroePage() {
// useParams lee el :id de la ruta /heroe/:id. Llega siempre como string.
const { id } = useParams();
// useNavigate permite volver por código (sin que el usuario pulse un enlace).
const navigate = useNavigate();
// Reusamos el mismo custom hook: TanStack Query sirve los datos de la caché, sin repetir la red.
const { data: heroes = [], isLoading } = useHeroes();
const equipo = useEquipo((s) => s.equipo);
const anadir = useEquipo((s) => s.anadir);
if (isLoading) {
return <p className="cargando">Cargando...</p>;
}
// Buscamos el héroe por id. El id de la ruta es string; lo comparamos como número.
const heroe = heroes.find((h) => h.id === Number(id));
// Si la URL trae un id que no existe, mostramos un aviso y un enlace de vuelta.
if (heroe === undefined) {
return (
<section>
<p className="vacio">Héroe no encontrado.</p>
<Link to="/" className="boton boton--secundario">
Volver al roster
</Link>
</section>
);
}
const yaEsta = equipo.some((e) => e.id === heroe.id);
return (
<section>
<button className="boton boton--secundario" onClick={() => navigate("/")}>
← Volver
</button>
<article className="detalle">
<h2 className="seccion__titulo">{heroe.nombre}</h2>
<p className="tarjeta__rol">{heroe.rol}</p>
<ul className="detalle__stats">
<li>Partidas: {heroe.partidas}</li>
<li>Victorias: {heroe.victorias}</li>
<li>Winrate: {winrate(heroe)}%</li>
</ul>
<button className="boton" disabled={yaEsta} onClick={() => anadir(heroe)}>
{yaEsta ? "En el equipo" : "Añadir al equipo"}
</button>
</article>
</section>
);
}
// ── App.tsx ─────────────────────────────────────────────────
import { Routes, Route, NavLink } from "react-router";
import { RosterPage } from "./RosterPage";
import { EquipoPage } from "./EquipoPage";
import { HeroePage } from "./HeroePage";
// App define el layout común (cabecera + navegación) y las rutas de la SPA,
// incluida la ruta dinámica de detalle /heroe/:id.
export function App() {
return (
<div className="app">
<header className="cabecera">
<h1 className="titulo">Team Builder</h1>
<nav className="nav">
<NavLink to="/" end className="nav__link">
Roster
</NavLink>
<NavLink to="/equipo" className="nav__link">
Mi equipo
</NavLink>
</nav>
</header>
<main>
<Routes>
<Route path="/" element={<RosterPage />} />
<Route path="/equipo" element={<EquipoPage />} />
{/* :id es un parámetro de ruta; HeroePage lo lee con useParams. */}
<Route path="/heroe/:id" element={<HeroePage />} />
</Routes>
</main>
</div>
);
}
// ── main.tsx ────────────────────────────────────────────────
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { App } from "./App";
import "./styles.css";
// El cliente de TanStack Query: va a nivel de módulo para no recrearse en cada render.
const queryClient = new QueryClient();
// Fíjate en que ya NO hay EquipoProvider: el estado del equipo vive en el store de Zustand,
// que no necesita Provider. Solo envolvemos con QueryClientProvider (data) y BrowserRouter (rutas).
createRoot(document.getElementById("root")!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
</StrictMode>,
); Por qué es mejor que el anterior
- La consulta se extrae a un custom hook useHeroes(): las páginas piden los datos sin saber que por debajo hay useQuery. Si cambia la fuente, se toca un solo sitio.
- Una tercera ruta /heroe/:id con useParams muestra la ficha del héroe. Reutiliza useHeroes (la caché), busca el héroe por id y permite volver con useNavigate. Routing dinámico del capítulo 11, integrado.
- El formulario añade aria-invalid y aria-describedby: accesibilidad real del capítulo de formularios. Y el winrate medio del equipo es un dato derivado, calculado en el render a partir de la lista: nada de estado duplicado.