Skip to content

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-router

INFO

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>
  )
}
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 historial

Rutas 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 ​

  1. Agrega una ruta /blog con una lista de posts de https://jsonplaceholder.typicode.com/posts?_limit=10 y una ruta /blog/:id con el detalle, usando loaders.
  2. Crea un componente Breadcrumbs que muestre la ruta actual como "Inicio / Productos / 15" usando useLocation, 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 + RouterProvider definen las rutas; Outlet dibuja la ruta hija dentro de un layout.
  • Navega con Link/NavLink (nunca a interno) y con useNavigate desde código.
  • useParams lee segmentos dinámicos (:id) y useSearchParams la query string.
  • Rutas protegidas con un componente que redirige con Navigate; la seguridad real va en el backend.
  • Los loader cargan datos antes de mostrar la ruta.

Academia Web — aprende, copia y construye.