Enrutamiento
En resumen: El enrutamiento conecta la dirección con la página: React Router muestra el componente de la ruta, los enlaces no recargan, y los parámetros como /product/:id salen de la dirección.
Problema
Un enlace que no lleva a ninguna parte
La tienda tiene un catálogo y una página para cada producto. Abre «Leche fresca» y vuelve.
Consola
Parece que está bien. Pero qué página ves vive solo en el estado del componente (productId). La dirección del navegador es la misma para el catálogo y para cada producto. Por eso:
- no puedes enviarle a un amigo un enlace a la leche: recibirá el catálogo;
- «Recargar» te devuelve al catálogo, porque el estado se pierde;
- «Atrás» en el navegador no vuelve al catálogo, sino que sale de la tienda;
- los productos son botones, no enlaces: «Abrir en una pestaña nueva» no funciona.
(El sandbox no tiene barra de direcciones, así que esto no se nota en él. En un sitio real lo ves enseguida.)
La idea
La dirección es el estado
Cada página necesita su propia dirección: / para el catálogo, /product/5 para la leche, /cart para el carrito. Entonces la dirección dice qué página mostrar, no el estado en memoria.
La aplicación sigue siendo una sola página (una single-page application): el navegador carga el HTML una vez. Con un clic en un enlace la biblioteca detiene la recarga, cambia la dirección con la API History del navegador y dibuja otro componente. Eso es lo que hace el router. Tiene una tabla de rutas: un patrón de dirección y un componente para él.
React Router da componentes para la tabla. Cada Route dice «para esta dirección dibuja este elemento»:
import { MemoryRouter, Routes, Route } from 'react-router';
export default function App() { return ( <MemoryRouter> <Header /> <Routes> <Route path="/" element={<Catalog />} /> <Route path="/product/:id" element={<ProductPage />} /> <Route path="/cart" element={<CartPage />} /> </Routes> </MemoryRouter> );}Routes dibuja solo una de sus rutas: la que coincide con la dirección. Header está fuera de Routes, así que se ve en todas las páginas.
:id en /product/:id es un parámetro: representa un segmento de la dirección. Una sola ruta cubre todos los productos.
Míralo
Mira cómo la dirección elige la página
<Link to={`/product/${p.id}`}>{p.name}</Link>
<Routes> <Route path="/" element={<Catalog />} /> <Route path="/product/:id" element={<ProductPage />} /> <Route path="/cart" element={<CartPage />} /> <Route path="*" element={<NotFound />} /></Routes>
function ProductPage() { const { id } = useParams(); const product = PRODUCTS.find((p) => p.id === Number(id)); return <h1>{product.name}</h1>;}
function NotFound() { return <h1>Esa página no existe</h1>;}Paso a paso, en palabras
- El router tiene una tabla: un patrón de dirección y el componente para él. La dirección ahora es /, el catálogo.
- Un clic en «Leche fresca». El router impide que la página se recargue y solo cambia la dirección a /product/5.
- Compara la dirección con cada fila. «/product/:id» coincide: :id representa cualquier segmento. La fila para todo lo demás también coincide, pero es más general y pierde.
- Del resultado sale el parámetro: id = "5". Es texto, porque la dirección es texto. useParams() se lo da a ProductPage, y Number(id) lo convierte en 5.
- ProductPage encuentra el producto 5 y dibuja su página. El mismo componente dibuja cada producto: solo cambia el parámetro.
- Ahora la dirección es /sale, y esa página todavía no existe. No coincide ninguna fila salvo la última, así que el router dibuja NotFound.
- La dirección es el estado de la página. Puedes compartirla, recargarla y volver a ella, y la tabla siempre da la misma página.
El router no sabe nada de productos. Compara el texto de la dirección con los patrones y le pasa el parámetro al componente. Qué significa 5 lo decide el componente.
La idea
En el sandbox y en un proyecto real
El router de un sitio real lee y escribe la dirección en la barra de direcciones del navegador. Eso es BrowserRouter. El sandbox de las lecciones es una página dentro de la página de la lección y no tiene dirección propia. Por eso aquí da un error claro:
Consola
En el sandbox usamos MemoryRouter: un router que guarda la dirección en memoria. Todo lo demás es igual: la tabla, los enlaces, los parámetros. La dirección la mostramos nosotros, al pie de la página.
En un proyecto real el router envuelve la aplicación en main.jsx:
import { createRoot } from 'react-dom/client';import { BrowserRouter } from 'react-router';import App from './App.jsx';
createRoot(document.getElementById('root')).render( <BrowserRouter> <App /> </BrowserRouter>,);Y algo más que el sandbox no muestra: el servidor. Cuando alguien abre /product/5 directamente o pulsa «Recargar», el navegador le pide esa dirección al servidor. Ese archivo no existe. El servidor debe devolver index.html para toda dirección que no sea un archivo, y entonces el router elige la página. El servidor de desarrollo de Vite lo hace solo. En nginx lo hace la línea try_files:
location / { try_files $uri /index.html;}Pruébalo
Una página para cada producto
La tabla tiene el catálogo y el carrito, y los productos llevan a /product/1, /product/5 y /product/7, pero esa ruta no existe. Añade una ruta con parámetro para la página del producto. ProductPage ahora siempre muestra el pan: haz que tome el id de la dirección. La comprobación abre la leche, luego el queso desde «Ver también», luego el pan desde el catálogo y mira el encabezado y la dirección.
Consola
Solución
App.jsximport { useState } from 'react';
import { MemoryRouter, Routes, Route } from 'react-router';
import Header from './Header.jsx';
import Catalog from './Catalog.jsx';
import ProductPage from './ProductPage.jsx';
import CartPage from './CartPage.jsx';
import Address from './Address.jsx';
export default function App() {
const [items, setItems] = useState([]);
function add(product) {
setItems([...items, product]);
}
return (
<MemoryRouter>
<Header count={items.length} />
<main>
<Routes>
<Route path="/" element={<Catalog />} />
<Route path="/product/:id" element={<ProductPage onAdd={add} />} />
<Route path="/cart" element={<CartPage items={items} />} />
</Routes>
</main>
<Address />
</MemoryRouter>
);
}
Header.jsximport { NavLink } from 'react-router';
export default function Header({ count }) {
return (
<nav>
<NavLink to="/" end>Catálogo</NavLink>
<NavLink to="/cart">Carrito ({count})</NavLink>
</nav>
);
}
Catalog.jsximport { Link } from 'react-router';
import { PRODUCTS, money } from './products.js';
export default function Catalog() {
return (
<>
<h1>Catálogo</h1>
<ul>
{PRODUCTS.map((p) => (
<li key={p.id}>
<Link to={`/product/${p.id}`}>{p.name}</Link>, {money(p.price)}
</li>
))}
</ul>
</>
);
}
ProductPage.jsximport { Link, useParams } from 'react-router';
import { PRODUCTS, money } from './products.js';
export default function ProductPage({ onAdd }) {
const { id } = useParams();
const product = PRODUCTS.find((p) => p.id === Number(id));
if (!product) return <p>No existe ese producto</p>;
const others = PRODUCTS.filter((p) => p.id !== product.id);
function buy() {
onAdd(product);
}
return (
<section>
<h1>{product.name}</h1>
<p>{money(product.price)}</p>
<button type="button" onClick={buy}>Añadir al carrito</button>
<h2>Ver también</h2>
<ul>
{others.map((p) => (
<li key={p.id}>
<Link to={`/product/${p.id}`}>{p.name}</Link>
</li>
))}
</ul>
</section>
);
}
CartPage.jsximport { money } from './products.js';
export default function CartPage({ items }) {
const total = items.reduce((sum, p) => sum + p.price, 0);
return (
<>
<h1>Carrito</h1>
{items.length === 0 && <p>El carrito está vacío</p>}
<ul>
{items.map((p, i) => (
<li key={i}>{p.name}, {money(p.price)}</li>
))}
</ul>
<p>Total: {money(total)}</p>
</>
);
}
NotFound.jsximport { Link } from 'react-router';
export default function NotFound() {
return (
<>
<h1>Esa página no existe</h1>
<p>
<Link to="/">Volver al catálogo</Link>
</p>
</>
);
}
Address.jsximport { useLocation } from 'react-router';
// El sandbox no tiene barra de direcciones: mostramos la dirección aquí.
export default function Address() {
const location = useLocation();
return <footer>Dirección: {location.pathname}</footer>;
}
products.jsexport const PRODUCTS = [
{ id: 1, name: 'Pan blanco', price: 1.2 },
{ id: 5, name: 'Leche fresca', price: 1.6 },
{ id: 7, name: 'Queso blanco', price: 7.9 },
];
export const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
style.cssbody { font-family: system-ui, sans-serif; margin: 0; }
nav { display: flex; gap: 16px; background: #f3efe6; padding: 8px 16px; }
nav a { color: #1d4f8a; }
nav a[aria-current='page'] { font-weight: 700; text-decoration: none; }
main { padding: 0 16px; }
footer { border-top: 1px solid #ddd; padding: 4px 16px; color: #555; font-family: ui-monospace, monospace; }
button { font: inherit; }
En App.jsx la ruta es <Route path="/product/:id" element={<ProductPage onAdd={add} />} />. En ProductPage.jsx el parámetro sale de const { id } = useParams();. El nombre id es el mismo que en el patrón :id.
El parámetro es texto: "5", mientras que id en los datos es un número. p.id === "5" es false para todos los productos. Compara con Number(id).
Pruébalo
Encuentra el error
Todas las páginas de producto dicen «No existe ese producto», y los productos están en los datos. ¿Qué línea tiene la culpa?
Elige la línea con el error.
id de useParams() es texto ("5"), y p.id es un número. El === estricto nunca es verdadero. Debe ser p.id === Number(id).La idea
Enlaces y navegación desde el código
Enlaces. A una página de la aplicación se llega con <Link to="/cart">. En la página es un <a href="/cart"> normal: «Abrir en una pestaña nueva» funciona y un lector de pantalla lo lee como enlace. Con un clic normal el router detiene la recarga (preventDefault) y solo cambia la dirección. Si escribes <a href> tú mismo, el navegador carga todo el sitio otra vez, y el carrito en memoria desaparece.
Para el menú existe NavLink: el enlace a la página actual recibe aria-current="page" y puede tener su propio estilo. Así tanto el ojo como el lector de pantalla saben dónde estás.
Navegar desde el código. A veces la página debe cambiar después de una acción, no después de un clic en un enlace: «Añadir al carrito» añade el producto y te lleva al carrito.
import { useNavigate } from 'react-router';
export default function ProductPage({ onAdd }) { const navigate = useNavigate(); // … function buy() { onAdd(product); navigate('/cart'); }}useNavigate() es un hook: lo llamas en el cuerpo del componente y obtienes una función navigate. La función en sí la llamas donde haga falta, por ejemplo en el manejador.
Usas un enlace cuando una persona elige adónde ir. Navegas desde el código cuando la página cambia por el resultado de una acción.
Pruébalo
Completa
¿De dónde saca la página del producto el id de la dirección?
export default function ProductPage() {
const { id } = ();
const product = PRODUCTS.find((p) => p.id === Number(id));
return <h1>{product.name}</h1>;
}useParams: devuelve un objeto con los parámetros de la ruta que coincide. Para /product/:id es { id: "5" }.La idea
Esa página no existe
Si la dirección no coincide con ninguna ruta, el router no dibuja nada. La persona ve un espacio vacío debajo del menú y no sabe qué pasó. Por eso la última ruta recoge todo lo demás y dibuja «Esa página no existe» con un enlace al catálogo.
<Route path="*" element={<NotFound />} />* coincide con cualquier dirección. El orden en Routes no importa: React Router ordena las rutas según lo exactas que son, y * gana solo cuando no coincide ninguna otra.
Una página para un producto que no existe (/product/99) es otro caso: la ruta coincide, pero falta el producto. Eso lo decide el componente, así que ProductPage dice «No existe ese producto».
Más precisamente, para curiosos
Rutas anidadas. Las páginas suelen compartir un marco: la cuenta con un menú a la izquierda y las subpáginas «Pedidos» y «Direcciones». Las rutas se anidan unas dentro de otras, y el padre dibuja <Outlet /> donde va el hijo.
Una query en la dirección. La búsqueda y los filtros también son estado que conviene poner en la dirección: /?q=leche&category=2. Se lee y se escribe con useSearchParams().
El mismo componente, un parámetro nuevo. De /product/5 a /product/7 React renderiza otra vez la misma ProductPage con un id nuevo, así que los cálculos del cuerpo son correctos. Pero su estado (un campo «cantidad», por ejemplo) se queda del producto anterior. Si debe empezar de cero, ponle una clave: <ProductPage key={id} /> en un componente que lea id.
Los modos de React Router. Aquí es el modo «declarativo»: Routes y Route en JSX. También hay un modo de datos (createBrowserRouter con un loader que carga datos antes de la página) y un modo «framework» con Vite. La idea de tabla, parámetros y enlaces es la misma.
Guards. La página «Finalizar compra» puede exigir iniciar sesión. El router puede comprobarlo antes del cambio y redirigir al inicio de sesión: en modo de datos con un loader y redirect.
Ejercicio
El carrito y el 404
El menú tiene «Ofertas» (/sale), pero esa página aún no existe y el espacio debajo del menú queda vacío. Y «Añadir al carrito» añade el producto, pero la persona se queda en la página del producto. Añade una ruta para todo lo demás que lleve a NotFound (el componente está listo) y haz que el botón vaya a /cart después de añadir. La comprobación abre «Ofertas», vuelve con «Volver al catálogo», compra pan y queso y mira el carrito, la dirección y el total.
Consola
Solución
App.jsximport { useState } from 'react';
import { MemoryRouter, Routes, Route } from 'react-router';
import Header from './Header.jsx';
import Catalog from './Catalog.jsx';
import ProductPage from './ProductPage.jsx';
import CartPage from './CartPage.jsx';
import NotFound from './NotFound.jsx';
import Address from './Address.jsx';
export default function App() {
const [items, setItems] = useState([]);
function add(product) {
setItems([...items, product]);
}
return (
<MemoryRouter>
<Header count={items.length} />
<main>
<Routes>
<Route path="/" element={<Catalog />} />
<Route path="/product/:id" element={<ProductPage onAdd={add} />} />
<Route path="/cart" element={<CartPage items={items} />} />
<Route path="*" element={<NotFound />} />
</Routes>
</main>
<Address />
</MemoryRouter>
);
}
Header.jsximport { NavLink } from 'react-router';
export default function Header({ count }) {
return (
<nav>
<NavLink to="/" end>Catálogo</NavLink>
<NavLink to="/cart">Carrito ({count})</NavLink>
<NavLink to="/sale">Ofertas</NavLink>
</nav>
);
}
Catalog.jsximport { Link } from 'react-router';
import { PRODUCTS, money } from './products.js';
export default function Catalog() {
return (
<>
<h1>Catálogo</h1>
<ul>
{PRODUCTS.map((p) => (
<li key={p.id}>
<Link to={`/product/${p.id}`}>{p.name}</Link>, {money(p.price)}
</li>
))}
</ul>
</>
);
}
ProductPage.jsximport { Link, useNavigate, useParams } from 'react-router';
import { PRODUCTS, money } from './products.js';
export default function ProductPage({ onAdd }) {
const { id } = useParams();
const navigate = useNavigate();
const product = PRODUCTS.find((p) => p.id === Number(id));
if (!product) return <p>No existe ese producto</p>;
const others = PRODUCTS.filter((p) => p.id !== product.id);
function buy() {
onAdd(product);
navigate('/cart');
}
return (
<section>
<h1>{product.name}</h1>
<p>{money(product.price)}</p>
<button type="button" onClick={buy}>Añadir al carrito</button>
<h2>Ver también</h2>
<ul>
{others.map((p) => (
<li key={p.id}>
<Link to={`/product/${p.id}`}>{p.name}</Link>
</li>
))}
</ul>
</section>
);
}
CartPage.jsximport { money } from './products.js';
export default function CartPage({ items }) {
const total = items.reduce((sum, p) => sum + p.price, 0);
return (
<>
<h1>Carrito</h1>
{items.length === 0 && <p>El carrito está vacío</p>}
<ul>
{items.map((p, i) => (
<li key={i}>{p.name}, {money(p.price)}</li>
))}
</ul>
<p>Total: {money(total)}</p>
</>
);
}
NotFound.jsximport { Link } from 'react-router';
export default function NotFound() {
return (
<>
<h1>Esa página no existe</h1>
<p>
<Link to="/">Volver al catálogo</Link>
</p>
</>
);
}
Address.jsximport { useLocation } from 'react-router';
// El sandbox no tiene barra de direcciones: mostramos la dirección aquí.
export default function Address() {
const location = useLocation();
return <footer>Dirección: {location.pathname}</footer>;
}
products.jsexport const PRODUCTS = [
{ id: 1, name: 'Pan blanco', price: 1.2 },
{ id: 5, name: 'Leche fresca', price: 1.6 },
{ id: 7, name: 'Queso blanco', price: 7.9 },
];
export const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
style.cssbody { font-family: system-ui, sans-serif; margin: 0; }
nav { display: flex; gap: 16px; background: #f3efe6; padding: 8px 16px; }
nav a { color: #1d4f8a; }
nav a[aria-current='page'] { font-weight: 700; text-decoration: none; }
main { padding: 0 16px; }
footer { border-top: 1px solid #ddd; padding: 4px 16px; color: #555; font-family: ui-monospace, monospace; }
button { font: inherit; }
La ruta es <Route path="*" element={<NotFound />} />. En ProductPage.jsx: const navigate = useNavigate(); en el cuerpo, y navigate('/cart') en buy después de onAdd(product). El carrito está en App, así que el contador del menú y CartPage lo ven enseguida.
La comprobación también mira el menú: en la página del carrito el enlace «Carrito (1)» tiene aria-current="page". Eso lo hace el router solo.
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Estado · Estado derivado · Estado compartido: hacia arriba · Estado compartido
- Continúa en: Velocidad: cuándo se vuelve a renderizar un componente · Del sandbox al proyecto · El escaparate de la tienda
- Ejercicios sobre el tema: Una aplicación completa