learning-front

Nivel 6 · React de cero a héroe (con TypeScript)

Routing con React Router

Varias pantallas en una SPA: rutas, parámetros y navegación sin recargar la página.

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:

  1. 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).
  2. Routes + Route: declaran qué componente renderizar para cada URL.
  3. Link: un enlace que navega sin recargar la página.
  4. useParams: lee los segmentos dinámicos de la URL (/heroes/:id{ id: "2" }).

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:

tsx
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#

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

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.

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.

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

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:

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