Tema
React Router
Una app de React con Vite es una SPA (Single Page Application): hay un solo index.html. Para tener varias "páginas" con URLs propias (/productos, /productos/15, /carrito) se usa React Router, la librería de rutas más popular. Usaremos React Router v7 en modo librería.
Concepto
En un sitio tradicional, cada clic en un enlace pide una página nueva al servidor. En una SPA, React Router intercepta la navegación, cambia la URL con la History API del navegador y muestra el componente correspondiente sin recargar. Es más rápido y conserva el estado.
txt
URL: /productos/15
│
React Router compara con las rutas definidas
│
┌──────┴───────────────────────────────┐
│ <Layout> (header, menú, footer) │
│ <Outlet /> → <DetalleProducto /> │ ← useParams() → { id: '15' }
└──────────────────────────────────────┘Instalación y configuración
bash
npm i react-routerINFO
En la versión 7, el paquete se llama react-router (antes era react-router-dom, que sigue existiendo como alias). React Router v7 también puede funcionar como framework completo (con renderizado en servidor); aquí usamos el modo librería, ideal para SPAs con Vite.
jsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { RouterProvider } from 'react-router'
import { router } from './router'
import './index.css'
createRoot(document.getElementById('root')).render(
<StrictMode>
<RouterProvider router={router} />
</StrictMode>
)jsx
import { createBrowserRouter } from 'react-router'
import Layout from './layouts/Layout'
import Inicio from './pages/Inicio'
import Productos from './pages/Productos'
import DetalleProducto from './pages/DetalleProducto'
import Contacto from './pages/Contacto'
import NoEncontrado from './pages/NoEncontrado'
export const router = createBrowserRouter([
{
path: '/',
element: <Layout />, // envoltura común
errorElement: <NoEncontrado />, // rutas inexistentes y errores
children: [
{ index: true, element: <Inicio /> }, // "/"
{ path: 'productos', element: <Productos /> }, // "/productos"
{ path: 'productos/:id', element: <DetalleProducto /> }, // "/productos/15"
{ path: 'contacto', element: <Contacto /> }
]
}
])jsx
import { NavLink, Outlet } from 'react-router'
import './Layout.css'
export default function Layout() {
// NavLink agrega la clase "active" al enlace de la ruta actual
const clase = ({ isActive }) => (isActive ? 'nav-link activo' : 'nav-link')
return (
<>
<header className="header">
<strong>MiTienda</strong>
<nav>
<NavLink to="/" end className={clase}>Inicio</NavLink>
<NavLink to="/productos" className={clase}>Productos</NavLink>
<NavLink to="/contacto" className={clase}>Contacto</NavLink>
</nav>
</header>
<main className="contenido">
{/* Aquí se dibuja la ruta hija activa */}
<Outlet />
</main>
<footer className="footer">© {new Date().getFullYear()} MiTienda</footer>
</>
)
}css
.header { display: flex; justify-content: space-between; align-items: center; padding: 1rem; border-bottom: 1px solid #eee; }
.header nav { display: flex; gap: 1rem; }
.nav-link { color: #444; text-decoration: none; }
.nav-link.activo { color: #6d28d9; font-weight: 700; }
.contenido { max-width: 960px; margin: 0 auto; padding: 1.5rem 1rem; min-height: 70vh; }
.footer { text-align: center; padding: 1rem; color: #888; }jsx
import { Link } from 'react-router'
export default function Inicio() {
return (
<section>
<h1>Bienvenido a MiTienda</h1>
{/* Link en lugar de <a>: navega sin recargar */}
<Link to="/productos">Ver productos →</Link>
</section>
)
}jsx
import { Link, useRouteError } from 'react-router'
export default function NoEncontrado() {
const error = useRouteError()
return (
<main style={{ textAlign: 'center', padding: '4rem 1rem' }}>
<h1>{error?.status === 404 ? '404 · Página no encontrada' : '¡Ups! Algo salió mal'}</h1>
<p>{error?.statusText ?? error?.message}</p>
<Link to="/">Volver al inicio</Link>
</main>
)
}Error común
Usar <a href="/productos"> para navegar dentro de la app. Recarga toda la página y pierdes el estado. Usa Link o NavLink. Las etiquetas a normales sólo para enlaces externos.
Rutas dinámicas: useParams
jsx
// src/pages/DetalleProducto.jsx
import { useEffect, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router'
export default function DetalleProducto() {
const { id } = useParams() // "/productos/15" → { id: "15" } (string)
const navigate = useNavigate()
const [producto, setProducto] = useState(null)
const [estado, setEstado] = useState('cargando')
useEffect(() => {
const c = new AbortController()
setEstado('cargando')
fetch(`https://dummyjson.com/products/${id}`, { signal: c.signal })
.then((r) => {
if (!r.ok) throw new Error()
return r.json()
})
.then((p) => { setProducto(p); setEstado('ok') })
.catch((e) => e.name !== 'AbortError' && setEstado('error'))
return () => c.abort()
}, [id])
if (estado === 'cargando') return <p>Cargando…</p>
if (estado === 'error') return <p>Producto no encontrado. <Link to="/productos">Volver</Link></p>
return (
<article>
<button onClick={() => navigate(-1)}>← Regresar</button>
<h1>{producto.title}</h1>
<img src={producto.thumbnail} alt={producto.title} width={240} />
<p>{producto.description}</p>
<strong>${producto.price}</strong>
<div>
<Link to={`/productos/${Number(id) + 1}`}>Siguiente producto →</Link>
</div>
</article>
)
}Query string: useSearchParams
Guardar filtros en la URL (/productos?categoria=laptops&orden=precio) permite compartir el enlace y que el botón "atrás" funcione:
jsx
// src/pages/Productos.jsx
import { useEffect, useState } from 'react'
import { Link, useSearchParams } from 'react-router'
export default function Productos() {
const [params, setParams] = useSearchParams()
const categoria = params.get('categoria') ?? ''
const [productos, setProductos] = useState([])
useEffect(() => {
const url = categoria
? `https://dummyjson.com/products/category/${categoria}?limit=12`
: 'https://dummyjson.com/products?limit=12'
fetch(url).then((r) => r.json()).then((d) => setProductos(d.products))
}, [categoria])
function cambiarCategoria(e) {
const valor = e.target.value
// Actualiza la URL; el componente se vuelve a renderizar
setParams(valor ? { categoria: valor } : {})
}
return (
<section>
<h1>Productos</h1>
<select value={categoria} onChange={cambiarCategoria}>
<option value="">Todas</option>
<option value="smartphones">Celulares</option>
<option value="laptops">Laptops</option>
<option value="fragrances">Perfumes</option>
</select>
<ul>
{productos.map((p) => (
<li key={p.id}>
<Link to={`/productos/${p.id}`}>{p.title}</Link> — ${p.price}
</li>
))}
</ul>
</section>
)
}Navegar desde código: useNavigate
jsx
import { useNavigate } from 'react-router'
function FormContacto() {
const navigate = useNavigate()
async function enviar(e) {
e.preventDefault()
// ...enviar datos
navigate('/gracias', { replace: true, state: { nombre: 'Ana' } })
}
return <form onSubmit={enviar}>{/* ... */}</form>
}
// navigate('/ruta') → ir a una ruta
// navigate(-1) → atrás
// navigate('/login', { replace: true }) → reemplaza la entrada del historialRutas protegidas
Sólo usuarios autenticados pueden ver ciertas páginas. Combinamos el useAuth de la lección de Context con un componente guardián:
jsx
import { Navigate, Outlet, useLocation } from 'react-router'
import { useAuth } from '../context/AuthContext'
export default function RutaProtegida({ roles }) {
const { usuario } = useAuth()
const location = useLocation()
if (!usuario) {
// Guarda a dónde quería ir para regresarlo tras el login
return <Navigate to="/login" replace state={{ desde: location.pathname }} />
}
if (roles && !roles.includes(usuario.rol)) {
return <Navigate to="/" replace />
}
return <Outlet />
}jsx
{
path: '/',
element: <Layout />,
children: [
{ index: true, element: <Inicio /> },
{ path: 'login', element: <Login /> },
{
// Todas las rutas hijas requieren sesión
element: <RutaProtegida />,
children: [
{ path: 'mi-cuenta', element: <MiCuenta /> },
{ path: 'pedidos', element: <Pedidos /> }
]
},
{
element: <RutaProtegida roles={['admin']} />,
children: [{ path: 'admin', element: <Admin /> }]
}
]
}jsx
import { useLocation, useNavigate } from 'react-router'
import { useAuth } from '../context/AuthContext'
export default function Login() {
const { login } = useAuth()
const navigate = useNavigate()
const location = useLocation()
const destino = location.state?.desde ?? '/mi-cuenta'
async function enviar(e) {
e.preventDefault()
const f = new FormData(e.currentTarget)
await login(f.get('email'), f.get('password'))
navigate(destino, { replace: true })
}
return (
<form onSubmit={enviar}>
<input name="email" type="email" required />
<input name="password" type="password" required />
<button>Entrar</button>
</form>
)
}Recuerda envolver RouterProvider con AuthProvider en main.jsx.
Seguridad
Proteger rutas en el frontend sólo oculta la interfaz. Cualquiera puede leer tu JavaScript. Los datos sensibles deben protegerse en el backend (verificando el token en cada petición, ver Autenticación en Node).
Loaders: cargar datos antes de mostrar la ruta
En lugar de useEffect, una ruta puede declarar un loader que se ejecuta antes de renderizarla. React Router espera los datos y los entrega con useLoaderData:
jsx
// src/pages/Usuario.jsx
import { useLoaderData, useNavigation } from 'react-router'
export async function usuarioLoader({ params }) {
const res = await fetch(`https://jsonplaceholder.typicode.com/users/${params.id}`)
if (!res.ok) throw new Response('Usuario no encontrado', { status: 404 })
return res.json()
}
export default function Usuario() {
const usuario = useLoaderData()
return (
<div>
<h1>{usuario.name}</h1>
<p>{usuario.email}</p>
</div>
)
}
// router.jsx
// { path: 'usuarios/:id', element: <Usuario />, loader: usuarioLoader }
// En el Layout, para mostrar un indicador global de carga:
// const navigation = useNavigation()
// {navigation.state === 'loading' && <BarraCarga />}Si el loader lanza un error (o un Response con status 404), se muestra el errorElement.
Buena práctica
Organiza las páginas en src/pages/ y los layouts en src/layouts/. Para apps grandes, carga cada página bajo demanda con lazy para reducir el tamaño inicial (ver Rendimiento).
Error común en producción
Al recargar /productos en un hosting, aparece un 404 porque el servidor busca un archivo productos.html que no existe. Hay que configurar el servidor para devolver siempre index.html (lo verás en Despliegue).
Ejercicios
- Agrega una ruta
/blogcon una lista de posts dehttps://jsonplaceholder.typicode.com/posts?_limit=10y una ruta/blog/:idcon el detalle, usando loaders. - Crea un componente
Breadcrumbsque muestre la ruta actual como "Inicio / Productos / 15" usandouseLocation, donde cada parte sea un enlace.
Ver solución
jsx
// 1. src/pages/Blog.jsx
import { Link, useLoaderData } from 'react-router'
export async function blogLoader() {
const res = await fetch('https://jsonplaceholder.typicode.com/posts?_limit=10')
if (!res.ok) throw new Response('Error', { status: res.status })
return res.json()
}
export function Blog() {
const posts = useLoaderData()
return (
<ul>
{posts.map((p) => (
<li key={p.id}><Link to={`/blog/${p.id}`}>{p.title}</Link></li>
))}
</ul>
)
}
export async function postLoader({ params }) {
const res = await fetch(`https://jsonplaceholder.typicode.com/posts/${params.id}`)
if (!res.ok) throw new Response('Post no encontrado', { status: 404 })
return res.json()
}
export function Post() {
const post = useLoaderData()
return (
<article>
<Link to="/blog">← Blog</Link>
<h1>{post.title}</h1>
<p>{post.body}</p>
</article>
)
}
// router.jsx (children de Layout)
// { path: 'blog', element: <Blog />, loader: blogLoader },
// { path: 'blog/:id', element: <Post />, loader: postLoader },jsx
// 2. src/components/Breadcrumbs.jsx
import { Link, useLocation } from 'react-router'
export default function Breadcrumbs() {
const { pathname } = useLocation()
const partes = pathname.split('/').filter(Boolean)
return (
<nav aria-label="Migas de pan">
<Link to="/">Inicio</Link>
{partes.map((parte, i) => {
const ruta = '/' + partes.slice(0, i + 1).join('/')
const texto = decodeURIComponent(parte).replace(/-/g, ' ')
const esUltima = i === partes.length - 1
return (
<span key={ruta}>
{' / '}
{esUltima ? <span aria-current="page">{texto}</span> : <Link to={ruta}>{texto}</Link>}
</span>
)
})}
</nav>
)
}Resumen
createBrowserRouter+RouterProviderdefinen las rutas;Outletdibuja la ruta hija dentro de un layout.- Navega con
Link/NavLink(nuncaainterno) y conuseNavigatedesde código. useParamslee segmentos dinámicos (:id) yuseSearchParamsla query string.- Rutas protegidas con un componente que redirige con
Navigate; la seguridad real va en el backend. - Los
loadercargan datos antes de mostrar la ruta.