Una app de React no es un sitio web de páginas sueltas#
Hasta ahora el Team Builder ha vivido en un solo componente que muestra todo a la vez. En una app real eso no escala: querrás una pantalla para el roster, otra para el detalle de cada héroe, otra para el equipo seleccionado.
La solución no es hacer tres ficheros HTML distintos. Eso te llevaría de vuelta a la web clásica: cada clic en un enlace descarga un HTML nuevo, el navegador repinta desde cero, el estado de React desaparece. La sensación es lenta y entrecortada.
Lo que quieres es una Single-Page Application (SPA): el navegador carga el HTML una sola vez, React se monta y, a partir de ahí, el JavaScript cambia la vista sin salir de la página. La navegación es instantánea porque no hay petición HTTP, no hay descarga, no hay pérdida de estado.
El trade-off honesto que merece mencionar: las SPAs tienen peor SEO y primera carga más lenta si no se usa renderizado del lado del servidor (SSR). Si estás construyendo un blog o un catálogo público que Google indexa, una MPA clásica o un framework con SSR (como Next.js, que verás más adelante) puede encajar mejor. Para aplicaciones donde el usuario está autenticado y la velocidad de interacción es lo que importa —dashboards, herramientas, apps internas—, la SPA es la elección habitual.
React Router: la librería de routing más usada en el ecosistema React#
React no incluye routing por defecto. Es una decisión deliberada: una librería de componentes no debería dictarte cómo manejas las URLs. Hay varias opciones, pero React Router lleva años siendo el estándar. Desde su versión 7, react-router-dom y react-router se fusionaron en un solo paquete: todo se importa de "react-router" (este capítulo usa la versión actual, que mantiene esa misma API).
Los cuatro conceptos que necesitas:
- Router: envuelve la app y gestiona la historia de navegación. En producción se usa
BrowserRouter(usa la URL del navegador). En el playground,MemoryRouter(guarda la historia en memoria, sin necesitar una URL real). - Routes + Route: declaran qué componente renderizar para cada URL.
- Link: un enlace que navega sin recargar la página.
- useParams: lee los segmentos dinámicos de la URL (
/heroes/:id→{ id: "2" }).
<Link> frente a <a href>: la diferencia que importa#
Este es el punto más importante de todo el capítulo.
Un <a href="/heroes"> normal hace lo que siempre ha hecho: envía una petición HTTP al servidor, que responde con el HTML, el navegador lo descarga, parsea los scripts y React arranca de nuevo desde cero. Todo el estado que habías construido desaparece: el equipo que habías seleccionado, los filtros aplicados, el héroe que estabas viendo.
<Link to="/heroes"> hace algo muy distinto: intercepta el clic, llama a preventDefault() internamente para que el navegador no haga la petición, y actualiza la historia de navegación del lado del cliente. React Router re-renderiza los componentes que corresponden a la nueva URL. La página no se recarga: el estado de React se conserva.
Esa diferencia —recarga completa frente a cambio de vista sin salir— es lo que define una SPA.
Declarar rutas con <Routes> y <Route>#
La estructura básica:
import { MemoryRouter, Routes, Route } from "react-router";
export default function App() {
return (
// El Router envuelve toda la app; en producción sería BrowserRouter
<MemoryRouter>
{/* Routes examina la URL actual y renderiza la Route que coincide */}
<Routes>
{/* Cuando la URL es /heroes, renderiza PantallaHeroes */}
<Route path="/heroes" element={<PantallaHeroes />} />
{/* :id es el parámetro dinámico: una sola Route sirve para todos los héroes */}
<Route path="/heroes/:id" element={<PantallaDetalle />} />
{/* Cuando la URL es /equipo, renderiza PantallaEquipo */}
<Route path="/equipo" element={<PantallaEquipo />} />
</Routes>
</MemoryRouter>
);
}<Routes> funciona como un switch: evalúa la URL actual y renderiza solo la primera Route que coincide. No pinta todas a la vez.
Por qué /heroes/:id y no una ruta por héroe#
Podrías declarar /heroes/tracer, /heroes/mercy, /heroes/reinhardt… pero con 50 héroes eso es inmanejable. Y si añades un héroe nuevo, tienes que añadir una ruta más.
El :id es un parámetro de ruta: un segmento dinámico que captura cualquier valor. /heroes/1, /heroes/2, /heroes/99 todas coinciden con /heroes/:id. El valor capturado ("1", "2", "99") llega al componente a través de useParams.
useParams: leer el parámetro de la URL#
import { useParams } from "react-router";
function PantallaDetalle() {
// Cuando la URL es /heroes/2, useParams devuelve { id: "2" }
// Fíjate: id llega siempre como string, aunque en la URL parezca un número
const { id } = useParams();
// Busca el héroe cuyo id (número) coincida con el id de la URL (string)
const heroe = ROSTER.find((h) => String(h.id) === id);
if (!heroe) {
return <p>Héroe no encontrado.</p>;
}
return <h1>{heroe.nombre}</h1>;
}La comparación String(h.id) === id es importante: en el array el id es un number, pero la URL lo entrega como string. Si comparas h.id === id directamente, TypeScript lo permite pero el valor nunca coincidirá (2 === "2" es false).
Pruébalo: navegación básica con Link y rutas#
El demo de abajo muestra el roster con enlaces al detalle de cada héroe. Pulsa en un héroe para ver cómo useParams lee su id desde la URL y carga sus datos. Usa el botón “Volver” para volver al roster.
Observa que al navegar no hay ningún parpadeo: la vista cambia en el sitio, sin recargar. El Router simplemente re-renderiza los componentes que corresponden a la nueva URL.
useParams en profundidad: el id siempre es string#
Veamos el componente de detalle completo:
Dos puntos que merecen atención:
El guard de !heroe: si alguien escribe /heroes/999 en la URL y no existe ese id, find devuelve undefined. Sin el if (!heroe) return ..., el componente intentaría acceder a heroe.nombre y petaría con un error en runtime. El guard hace que en ese caso se muestre un mensaje útil en vez de un crash.
String(h.id) === id: TypeScript te avisa si comparas number === string directamente. Conviértelo explícitamente. En un proyecto con una API real, el id podría llegar como string desde la base de datos, así que la conversión es el patrón correcto.
NavLink: el enlace que sabe dónde estás#
Link navega. NavLink hace lo mismo, pero además llama a la función className con { isActive }, que es true cuando la URL actual coincide con su to. Eso permite resaltar el enlace de la pantalla que se está viendo.
import { NavLink } from "react-router";
function Nav() {
// isActive es true cuando la URL actual coincide con el "to" de este NavLink
function claseNav({ isActive }: { isActive: boolean }) {
return isActive ? "nav__enlace nav__enlace--activo" : "nav__enlace";
}
return (
<nav className="nav">
{/* Cuando la URL empieza por /heroes, isActive es true aquí */}
<NavLink to="/heroes" className={claseNav} end={false}>Héroes</NavLink>
{/* Cuando la URL es /equipo, isActive es true aquí */}
<NavLink to="/equipo" className={claseNav}>Equipo</NavLink>
</nav>
);
}La prop end={false} en el NavLink de /heroes evita que se desactive cuando la URL es /heroes/2: sin ella, React Router solo marcaría como activo un NavLink cuya ruta coincida exactamente con la URL, y /heroes no coincide exactamente con /heroes/2.
Pruébalo: la app completa con NavLink y tres pantallas#
Fíjate en la barra de navegación: el enlace de la pantalla activa tiene el color del acento. Navega al detalle de un héroe: el enlace “Héroes” sigue resaltado porque la URL empieza por /heroes. Navega a “Equipo”: el resaltado se mueve.
useNavigate: navegar por código#
A veces quieres navegar no porque el usuario pulsó un enlace, sino porque acaba de ocurrir algo: un formulario se envió, una acción se completó, una condición se cumplió. Para eso existe useNavigate:
import { useNavigate } from "react-router";
function PantallaDetalle() {
// navegar es una función que acepta una ruta
const navegar = useNavigate();
function manejarAnadir() {
onAnadir(heroe);
// Después de añadir el héroe, lleva al usuario a su equipo para que vea el resultado
navegar("/equipo");
}
return (
<button onClick={manejarAnadir}>Añadir al equipo</button>
);
}La diferencia con Link: navegar es una función que llamas en el momento que decides, dentro de un manejador de evento o de cualquier lógica. Link es un elemento visible que el usuario pulsa.
MemoryRouter frente a BrowserRouter#
El playground usa MemoryRouter porque vive en un iframe que no tiene una URL propia en la barra de direcciones del navegador. BrowserRouter usa la API de History del navegador (window.history) para leer y escribir la URL; en un iframe sin URL propia, eso falla o navega fuera del contexto del demo.
En tu app de producción siempre usarás BrowserRouter. Es idéntico en API; la única diferencia es de dónde lee y guarda la historia de navegación: BrowserRouter en la URL, MemoryRouter en memoria.
Comprueba lo que sabes#
Pregunta 1 de 4
¿Qué ocurre exactamente cuando usas <a href='/heroes'> en vez de <Link to='/heroes'> en una SPA?
Tu turno#
Ejercicio · en esta página
Tres pantallas con React Router
Monta el routing completo del Team Builder: una pantalla con el roster de héroes, una de detalle (con parámetro de ruta), y una con el equipo seleccionado. La navegación entre ellas debe ser instantánea, sin recargar la página.
Paso 1: Tres rutas básicas funcionando
- MemoryRouter con tres Routes: /heroes, /heroes/:id y /equipo
- Link en las tarjetas del roster para navegar al detalle de cada héroe
- useParams en PantallaDetalle para leer el :id y mostrar los datos del héroe
- Link de vuelta desde el detalle al roster
- Nav con enlaces a /heroes y /equipo
Paso 2: NavLink activo y useNavigate
- NavLink en la barra de navegación con clase nav__enlace--activo en el enlace de la pantalla actual
- useNavigate en PantallaDetalle para navegar al equipo tras añadir o quitar un héroe
- Regla de negocio: no duplicar héroes; máximo 6 en el equipo
- Winrate medio del equipo mostrado en PantallaEquipo
Paso 3: Componentes reutilizables, ranking derivado y mobile-first
- TarjetaHeroe es un componente separado reutilizado en el roster
- La lista del equipo en PantallaEquipo se ordena por winrate (ranking derivado en el render, no en el estado)
- Las reglas de negocio (no duplicar, máximo 6) viven en App, no repartidas por los hijos
- La UI aguanta a 375px sin romperse: flex-wrap, tarjetas con max-width, texto no desbordado
Ver soluciones
import { useState } from "react";
import {
MemoryRouter,
Routes,
Route,
Link,
useParams,
} from "react-router";
interface Heroe {
id: number;
nombre: string;
rol: string;
partidas: number;
victorias: number;
}
const ROSTER: Heroe[] = [
{ id: 1, nombre: "Tracer", rol: "Daño", partidas: 200, victorias: 120 },
{ id: 2, nombre: "Mercy", rol: "Apoyo", partidas: 180, victorias: 126 },
{ id: 3, nombre: "Reinhardt", rol: "Tanque", partidas: 150, victorias: 90 },
{ id: 4, nombre: "Ana", rol: "Apoyo", partidas: 160, victorias: 96 },
{ id: 5, nombre: "Genji", rol: "Daño", partidas: 220, victorias: 110 },
{ id: 6, nombre: "D.Va", rol: "Tanque", partidas: 170, victorias: 102 },
];
// Pantalla con el grid de héroes disponibles
function PantallaHeroes({ equipo, onAnadir, onQuitar }: {
equipo: Heroe[];
onAnadir: (h: Heroe) => void;
onQuitar: (id: number) => void;
}) {
return (
<div className="pagina">
<h1 className="titulo">Roster</h1>
<div className="lista">
{ROSTER.map((heroe) => {
// Comprueba si el héroe ya está en el equipo para cambiar el botón
const enEquipo = equipo.some((h) => h.id === heroe.id);
return (
<article key={heroe.id} className="tarjeta">
<h3 className="tarjeta__nombre">{heroe.nombre}</h3>
<p className="tarjeta__rol">{heroe.rol}</p>
{/* Link navega sin recargar la página: no pierde el estado de React */}
<Link to={"/heroes/" + heroe.id} className="boton boton--secundario">
Ver detalle
</Link>
<br /><br />
<button
className={enEquipo ? "boton boton--secundario" : "boton"}
onClick={() => (enEquipo ? onQuitar(heroe.id) : onAnadir(heroe))}
>
{enEquipo ? "Quitar" : "Añadir"}
</button>
</article>
);
})}
</div>
</div>
);
}
// Pantalla de detalle de un héroe concreto
function PantallaDetalle({ equipo, onAnadir, onQuitar }: {
equipo: Heroe[];
onAnadir: (h: Heroe) => void;
onQuitar: (id: number) => void;
}) {
// useParams lee los segmentos dinámicos de la URL actual (el ":id" de la ruta)
const { id } = useParams();
// Busca el héroe cuyo id coincide con el de la URL; id viene como string
const heroe = ROSTER.find((h) => String(h.id) === id);
// Si el id de la URL no corresponde a ningún héroe, muestra un mensaje de error
if (!heroe) {
return (
<div className="pagina">
<p>Héroe no encontrado.</p>
<Link to="/heroes" className="boton boton--secundario">Volver</Link>
</div>
);
}
const enEquipo = equipo.some((h) => h.id === heroe.id);
// Winrate calculado en el render; no hace falta guardarlo en estado
const winrate = Math.round((heroe.victorias / heroe.partidas) * 100);
return (
<div className="pagina">
<h1 className="titulo">{heroe.nombre}</h1>
<div className="detalle">
<div className="detalle__campo">
<span className="detalle__etiqueta">Rol</span>
<span>{heroe.rol}</span>
</div>
<div className="detalle__campo">
<span className="detalle__etiqueta">Partidas</span>
<span>{heroe.partidas}</span>
</div>
<div className="detalle__campo">
<span className="detalle__etiqueta">Victorias</span>
<span>{heroe.victorias}</span>
</div>
<div className="detalle__campo">
<span className="detalle__etiqueta">Winrate</span>
<span className="tarjeta__winrate">{winrate}%</span>
</div>
</div>
<br />
<button
className={enEquipo ? "boton boton--secundario" : "boton"}
onClick={() => (enEquipo ? onQuitar(heroe.id) : onAnadir(heroe))}
>
{enEquipo ? "Quitar del equipo" : "Añadir al equipo"}
</button>
<br /><br />
{/* Link de vuelta: igual que <a>, pero sin recargar la página */}
<Link to="/heroes" className="boton boton--secundario">Volver al roster</Link>
</div>
);
}
// Pantalla con los héroes seleccionados
function PantallaEquipo({ equipo, onQuitar }: {
equipo: Heroe[];
onQuitar: (id: number) => void;
}) {
return (
<div className="pagina">
<h1 className="titulo">Mi equipo</h1>
<p className="stats">Héroes seleccionados: {equipo.length} / 6</p>
{equipo.length === 0 ? (
<p className="equipo-vacio">El equipo está vacío. Ve al roster y añade héroes.</p>
) : (
<ul className="equipo-lista">
{equipo.map((heroe) => (
<li key={heroe.id} className="equipo-item">
<span>{heroe.nombre} — {heroe.rol}</span>
<button
className="boton boton--secundario"
onClick={() => onQuitar(heroe.id)}
>
Quitar
</button>
</li>
))}
</ul>
)}
</div>
);
}
// Barra de navegación compartida entre todas las pantallas
function Nav() {
return (
<nav className="nav">
{/* Link navega sin recargar; el estado del equipo (useState) se conserva */}
<Link to="/heroes" className="nav__enlace">Héroes</Link>
<Link to="/equipo" className="nav__enlace">Equipo</Link>
</nav>
);
}
export default function App() {
// El estado del equipo vive en App; lo pasamos a las pantallas por props
const [equipo, setEquipo] = useState<Heroe[]>([]);
function anadir(heroe: Heroe) {
// Nuevo array: nunca mutamos el estado directamente
setEquipo((e) => [...e, heroe]);
}
function quitar(id: number) {
setEquipo((e) => e.filter((h) => h.id !== id));
}
return (
// MemoryRouter gestiona la historia de navegación en memoria.
// En producción usarías BrowserRouter, que sincroniza con la URL del navegador.
<MemoryRouter initialEntries={["/heroes"]}>
<Nav />
{/* Routes examina la URL actual y renderiza solo la Route que coincide */}
<Routes>
<Route
path="/heroes"
element={
<PantallaHeroes equipo={equipo} onAnadir={anadir} onQuitar={quitar} />
}
/>
{/* :id es el segmento dinámico; useParams() lo leerá como string */}
<Route
path="/heroes/:id"
element={
<PantallaDetalle equipo={equipo} onAnadir={anadir} onQuitar={quitar} />
}
/>
<Route
path="/equipo"
element={<PantallaEquipo equipo={equipo} onQuitar={quitar} />}
/>
</Routes>
</MemoryRouter>
);
} Por qué este nivel
- Las tres rutas están declaradas en Routes y cada Link navega sin recargar. Ese es el núcleo del routing en una SPA: cambiar la vista sin salir de la página.
- useParams lee el :id de la URL y busca el héroe en el ROSTER con find(). El id siempre llega como string desde la URL, por eso se compara con String(h.id).
- El guard 'if (!heroe)' protege la pantalla: si alguien escribe un id que no existe, ve un mensaje de error en vez de un crash.
import { useState } from "react";
import {
MemoryRouter,
Routes,
Route,
Link,
NavLink,
useParams,
useNavigate,
} from "react-router";
interface Heroe {
id: number;
nombre: string;
rol: string;
partidas: number;
victorias: number;
}
const ROSTER: Heroe[] = [
{ id: 1, nombre: "Tracer", rol: "Daño", partidas: 200, victorias: 120 },
{ id: 2, nombre: "Mercy", rol: "Apoyo", partidas: 180, victorias: 126 },
{ id: 3, nombre: "Reinhardt", rol: "Tanque", partidas: 150, victorias: 90 },
{ id: 4, nombre: "Ana", rol: "Apoyo", partidas: 160, victorias: 96 },
{ id: 5, nombre: "Genji", rol: "Daño", partidas: 220, victorias: 110 },
{ id: 6, nombre: "D.Va", rol: "Tanque", partidas: 170, victorias: 102 },
];
// Calcula el winrate en porcentaje con un decimal
function calcularWinrate(heroe: Heroe): number {
return Math.round((heroe.victorias / heroe.partidas) * 1000) / 10;
}
// Pantalla con el grid de héroes disponibles
function PantallaHeroes({ equipo, onAnadir, onQuitar }: {
equipo: Heroe[];
onAnadir: (h: Heroe) => void;
onQuitar: (id: number) => void;
}) {
return (
<div className="pagina">
<h1 className="titulo">Roster</h1>
<div className="lista">
{ROSTER.map((heroe) => {
const enEquipo = equipo.some((h) => h.id === heroe.id);
return (
<article key={heroe.id} className="tarjeta">
<h3 className="tarjeta__nombre">{heroe.nombre}</h3>
<p className="tarjeta__rol">{heroe.rol}</p>
<p className="tarjeta__winrate">{calcularWinrate(heroe)}% victorias</p>
{/* Link navega sin recargar; el estado de React (equipo) se conserva */}
<Link to={"/heroes/" + heroe.id} className="boton boton--secundario">
Ver detalle
</Link>
<br /><br />
<button
className={enEquipo ? "boton boton--secundario" : "boton"}
// Regla de negocio: máximo 6 héroes en el equipo
disabled={!enEquipo && equipo.length >= 6}
onClick={() => (enEquipo ? onQuitar(heroe.id) : onAnadir(heroe))}
>
{enEquipo ? "Quitar" : equipo.length >= 6 ? "Equipo lleno" : "Añadir"}
</button>
</article>
);
})}
</div>
</div>
);
}
// Pantalla de detalle de un héroe concreto, con navegación programática
function PantallaDetalle({ equipo, onAnadir, onQuitar }: {
equipo: Heroe[];
onAnadir: (h: Heroe) => void;
onQuitar: (id: number) => void;
}) {
// useParams lee el segmento dinámico :id de la URL
const { id } = useParams();
// useNavigate devuelve una función para navegar por código (no por clic en un Link)
const navegar = useNavigate();
const heroe = ROSTER.find((h) => String(h.id) === id);
if (!heroe) {
return (
<div className="pagina">
<p>Héroe no encontrado.</p>
<Link to="/heroes" className="boton boton--secundario">Volver</Link>
</div>
);
}
const enEquipo = equipo.some((h) => h.id === heroe.id);
const winrate = calcularWinrate(heroe);
function manejarEquipo() {
if (enEquipo) {
onQuitar(heroe.id);
} else {
onAnadir(heroe);
}
// Después de añadir o quitar, navega al equipo para que el alumno vea el resultado
navegar("/equipo");
}
return (
<div className="pagina">
<h1 className="titulo">{heroe.nombre}</h1>
<div className="detalle">
<div className="detalle__campo">
<span className="detalle__etiqueta">Rol</span>
<span>{heroe.rol}</span>
</div>
<div className="detalle__campo">
<span className="detalle__etiqueta">Partidas</span>
<span>{heroe.partidas}</span>
</div>
<div className="detalle__campo">
<span className="detalle__etiqueta">Victorias</span>
<span>{heroe.victorias}</span>
</div>
<div className="detalle__campo">
<span className="detalle__etiqueta">Winrate</span>
<span className="tarjeta__winrate">{winrate}%</span>
</div>
</div>
<br />
<button
className={enEquipo ? "boton boton--secundario" : "boton"}
disabled={!enEquipo && equipo.length >= 6}
onClick={manejarEquipo}
>
{enEquipo ? "Quitar del equipo" : equipo.length >= 6 ? "Equipo lleno" : "Añadir al equipo"}
</button>
<br /><br />
<Link to="/heroes" className="boton boton--secundario">Volver al roster</Link>
</div>
);
}
// Pantalla con los héroes del equipo seleccionado
function PantallaEquipo({ equipo, onQuitar }: {
equipo: Heroe[];
onQuitar: (id: number) => void;
}) {
// El winrate medio se calcula en el render, no se guarda en estado
const winrateMedio = equipo.length === 0
? 0
: Math.round(
equipo.reduce((suma, h) => suma + calcularWinrate(h), 0) / equipo.length * 10
) / 10;
return (
<div className="pagina">
<h1 className="titulo">Mi equipo</h1>
<p className="stats">
{equipo.length} / 6 héroes
{equipo.length > 0 && (" — winrate medio: " + winrateMedio + "%")}
</p>
{equipo.length === 0 ? (
<p className="equipo-vacio">El equipo está vacío. Ve al roster y añade héroes.</p>
) : (
<ul className="equipo-lista">
{equipo.map((heroe) => (
<li key={heroe.id} className="equipo-item">
<span>{heroe.nombre} — {heroe.rol}</span>
<button
className="boton boton--secundario"
onClick={() => onQuitar(heroe.id)}
>
Quitar
</button>
</li>
))}
</ul>
)}
</div>
);
}
// NavLink: igual que Link, pero añade una clase cuando la ruta activa coincide
function Nav() {
return (
<nav className="nav">
{/* La función className de NavLink recibe { isActive } para aplicar estilos */}
<NavLink
to="/heroes"
className={({ isActive }) =>
isActive ? "nav__enlace nav__enlace--activo" : "nav__enlace"
}
>
Héroes
</NavLink>
<NavLink
to="/equipo"
className={({ isActive }) =>
isActive ? "nav__enlace nav__enlace--activo" : "nav__enlace"
}
>
Equipo
</NavLink>
</nav>
);
}
export default function App() {
const [equipo, setEquipo] = useState<Heroe[]>([]);
function anadir(heroe: Heroe) {
// Regla de negocio en la capa de datos: no se añade si ya está o si hay 6
if (equipo.some((h) => h.id === heroe.id) || equipo.length >= 6) return;
setEquipo((e) => [...e, heroe]);
}
function quitar(id: number) {
setEquipo((e) => e.filter((h) => h.id !== id));
}
return (
<MemoryRouter initialEntries={["/heroes"]}>
<Nav />
<Routes>
<Route
path="/heroes"
element={
<PantallaHeroes equipo={equipo} onAnadir={anadir} onQuitar={quitar} />
}
/>
<Route
path="/heroes/:id"
element={
<PantallaDetalle equipo={equipo} onAnadir={anadir} onQuitar={quitar} />
}
/>
<Route
path="/equipo"
element={<PantallaEquipo equipo={equipo} onQuitar={quitar} />}
/>
</Routes>
</MemoryRouter>
);
} Por qué es mejor que el anterior
- NavLink recibe una función en className que React Router llama con { isActive }. Así el enlace de la pantalla activa recibe la clase --activo automáticamente, sin lógica manual.
- useNavigate devuelve una función para navegar por código después de una acción (añadir/quitar). La diferencia con Link: aquí la navegación depende de lo que acaba de pasar, no de un clic directo en un enlace.
- Las reglas de negocio (no duplicar, máximo 6) están en la función anadir de App, no en los componentes hijos. Un solo sitio que las conoce es más fácil de modificar y de testear.
import { useState } from "react";
import {
MemoryRouter,
Routes,
Route,
Link,
NavLink,
useParams,
useNavigate,
} from "react-router";
interface Heroe {
id: number;
nombre: string;
rol: string;
partidas: number;
victorias: number;
}
const ROSTER: Heroe[] = [
{ id: 1, nombre: "Tracer", rol: "Daño", partidas: 200, victorias: 120 },
{ id: 2, nombre: "Mercy", rol: "Apoyo", partidas: 180, victorias: 126 },
{ id: 3, nombre: "Reinhardt", rol: "Tanque", partidas: 150, victorias: 90 },
{ id: 4, nombre: "Ana", rol: "Apoyo", partidas: 160, victorias: 96 },
{ id: 5, nombre: "Genji", rol: "Daño", partidas: 220, victorias: 110 },
{ id: 6, nombre: "D.Va", rol: "Tanque", partidas: 170, victorias: 102 },
];
// Calcula el winrate con un decimal; puro (sin efectos secundarios), testeable aparte
function calcularWinrate(heroe: Heroe): number {
return Math.round((heroe.victorias / heroe.partidas) * 1000) / 10;
}
// Tipado de los props que todas las pantallas comparten
interface PropsEquipo {
equipo: Heroe[];
onAnadir: (h: Heroe) => void;
onQuitar: (id: number) => void;
}
// Tarjeta de héroe reutilizable: la misma en el roster y en el detalle
function TarjetaHeroe({ heroe, enEquipo, equipoLleno, onAnadir, onQuitar }: {
heroe: Heroe;
enEquipo: boolean;
equipoLleno: boolean;
onAnadir: (h: Heroe) => void;
onQuitar: (id: number) => void;
}) {
const winrate = calcularWinrate(heroe);
return (
<article className="tarjeta">
<h3 className="tarjeta__nombre">{heroe.nombre}</h3>
<p className="tarjeta__rol">{heroe.rol}</p>
<p className="tarjeta__winrate">{winrate}% victorias</p>
{/* Link internamente hace preventDefault del evento del <a>, evitando la recarga */}
<Link to={"/heroes/" + heroe.id} className="boton boton--secundario">
Ver detalle
</Link>
<br /><br />
<button
className={enEquipo ? "boton boton--secundario" : "boton"}
disabled={!enEquipo && equipoLleno}
onClick={() => (enEquipo ? onQuitar(heroe.id) : onAnadir(heroe))}
>
{enEquipo ? "Quitar" : equipoLleno ? "Equipo lleno" : "Añadir"}
</button>
</article>
);
}
// Pantalla del roster: grid con todas las tarjetas
function PantallaHeroes({ equipo, onAnadir, onQuitar }: PropsEquipo) {
const equipoLleno = equipo.length >= 6;
return (
<div className="pagina">
<h1 className="titulo">Roster</h1>
<p className="stats">{equipo.length} / 6 héroes en el equipo</p>
<div className="lista">
{ROSTER.map((heroe) => (
<TarjetaHeroe
key={heroe.id}
heroe={heroe}
// Derivado: si está en el equipo; no se guarda en estado
enEquipo={equipo.some((h) => h.id === heroe.id)}
equipoLleno={equipoLleno}
onAnadir={onAnadir}
onQuitar={onQuitar}
/>
))}
</div>
</div>
);
}
// Pantalla de detalle: lee el :id de la URL con useParams
function PantallaDetalle({ equipo, onAnadir, onQuitar }: PropsEquipo) {
// useParams devuelve un objeto con los segmentos dinámicos de la URL
// El tipo de :id es string | undefined; siempre llegará como string desde la ruta
const { id } = useParams<{ id: string }>();
// useNavigate devuelve una función para navegar por código después de una acción
const navegar = useNavigate();
// id viene como string desde la URL; comparamos con String(h.id) para que los tipos cuadren
const heroe = ROSTER.find((h) => String(h.id) === id);
if (!heroe) {
return (
<div className="pagina">
<p>Héroe no encontrado.</p>
<Link to="/heroes" className="boton boton--secundario">Volver al roster</Link>
</div>
);
}
const enEquipo = equipo.some((h) => h.id === heroe.id);
const equipoLleno = equipo.length >= 6;
const winrate = calcularWinrate(heroe);
function manejarEquipo() {
if (enEquipo) {
onQuitar(heroe.id);
} else {
onAnadir(heroe);
}
// Navegación programática tras la acción: lleva al equipo para ver el resultado.
// Es la única diferencia respecto a un botón normal: useNavigate actúa como Link
// pero en código, útil cuando la navegación depende de una condición o de una acción.
navegar("/equipo");
}
return (
<div className="pagina">
<h1 className="titulo">{heroe.nombre}</h1>
<div className="detalle">
<div className="detalle__campo">
<span className="detalle__etiqueta">Rol</span>
<span>{heroe.rol}</span>
</div>
<div className="detalle__campo">
<span className="detalle__etiqueta">Partidas</span>
<span>{heroe.partidas}</span>
</div>
<div className="detalle__campo">
<span className="detalle__etiqueta">Victorias</span>
<span>{heroe.victorias}</span>
</div>
<div className="detalle__campo">
<span className="detalle__etiqueta">Winrate</span>
<span className="tarjeta__winrate">{winrate}%</span>
</div>
</div>
<br />
<button
className={enEquipo ? "boton boton--secundario" : "boton"}
disabled={!enEquipo && equipoLleno}
onClick={manejarEquipo}
>
{enEquipo
? "Quitar del equipo"
: equipoLleno
? "Equipo lleno"
: "Añadir al equipo"}
</button>
<br /><br />
<Link to="/heroes" className="boton boton--secundario">Volver al roster</Link>
</div>
);
}
// Pantalla del equipo: lista de héroes seleccionados, ordenada por winrate
function PantallaEquipo({ equipo, onQuitar }: Pick<PropsEquipo, "equipo" | "onQuitar">) {
// El ranking se deriva del equipo en el render; no va al estado.
// Si el equipo cambia, el ordenamiento se recalcula solo.
const ranking = [...equipo].sort(
(a, b) => calcularWinrate(b) - calcularWinrate(a)
);
const winrateMedio = equipo.length === 0
? 0
: Math.round(
equipo.reduce((suma, h) => suma + calcularWinrate(h), 0) / equipo.length * 10
) / 10;
return (
<div className="pagina">
<h1 className="titulo">Mi equipo</h1>
<p className="stats">
{equipo.length} / 6 héroes
{equipo.length > 0 && (" — winrate medio: " + winrateMedio + "%")}
</p>
{equipo.length === 0 ? (
<p className="equipo-vacio">El equipo está vacío. Ve al roster y añade héroes.</p>
) : (
<ul className="equipo-lista">
{ranking.map((heroe) => (
<li key={heroe.id} className="equipo-item">
<span>
{heroe.nombre}
{" — "}
{heroe.rol}
{" — "}
{calcularWinrate(heroe)}%
</span>
<button
className="boton boton--secundario"
onClick={() => onQuitar(heroe.id)}
>
Quitar
</button>
</li>
))}
</ul>
)}
</div>
);
}
// NavLink vs Link: NavLink detecta si su ruta coincide con la URL actual
// y llama a la función className con { isActive } para poder aplicar estilos al enlace activo.
// Link no lo hace: es un <a> que navega, sin más.
function Nav() {
// Función reutilizable para no repetir la misma lógica de className en cada NavLink
function claseNav({ isActive }: { isActive: boolean }) {
return isActive ? "nav__enlace nav__enlace--activo" : "nav__enlace";
}
return (
<nav className="nav">
{/* NavLink para /heroes: isActive es true cuando la URL empieza por /heroes */}
<NavLink to="/heroes" className={claseNav} end={false}>
Héroes
</NavLink>
{/* NavLink para /equipo */}
<NavLink to="/equipo" className={claseNav}>
Equipo
</NavLink>
</nav>
);
}
export default function App() {
const [equipo, setEquipo] = useState<Heroe[]>([]);
// Las reglas de negocio viven en App (dueño del estado), no en los componentes hijos
function anadir(heroe: Heroe) {
// Guarda: no duplicados, máximo 6
if (equipo.some((h) => h.id === heroe.id) || equipo.length >= 6) return;
setEquipo((e) => [...e, heroe]);
}
function quitar(id: number) {
setEquipo((e) => e.filter((h) => h.id !== id));
}
return (
// MemoryRouter: la historia de navegación vive en memoria, no en la barra de direcciones.
// Imprescindible en el playground (el iframe no tiene URL propia).
// En producción: <BrowserRouter> sincroniza con la URL del navegador.
<MemoryRouter initialEntries={["/heroes"]}>
<Nav />
{/* Routes renderiza solo la primera Route cuyo path coincide con la URL actual */}
<Routes>
<Route
path="/heroes"
element={
<PantallaHeroes equipo={equipo} onAnadir={anadir} onQuitar={quitar} />
}
/>
{/* :id es el parámetro dinámico; useParams() lo lee en PantallaDetalle */}
<Route
path="/heroes/:id"
element={
<PantallaDetalle equipo={equipo} onAnadir={anadir} onQuitar={quitar} />
}
/>
<Route
path="/equipo"
element={<PantallaEquipo equipo={equipo} onQuitar={quitar} />}
/>
</Routes>
</MemoryRouter>
);
} Por qué es mejor que el anterior
- TarjetaHeroe es un componente separado con sus props tipadas. Se reutiliza en el roster; si hubiera más pantallas con tarjetas, se usaría también allí sin duplicar código.
- El ranking del equipo se calcula en el render con [...equipo].sort(...): estado mínimo en App, datos derivados en el componente que los necesita. Si se guardara en estado, habría que sincronizarlo manualmente cada vez que cambia el equipo.
- A 375px, flex: 1 1 160px y max-width en las tarjetas permiten que el grid baje a una columna sin que el layout se rompa. Es mobile-first sin una media query extra.