Módulo 18: React Router 8

Objetivos del módulo

  • Entender qué hace un router en el cliente y por qué React no trae uno propio
  • Distinguir los tres modos de React Router (Declarativo, Datos y Framework) y elegir el tuyo
  • Saber qué ha cambiado de la versión 7 a la 8: paquetes, rutas de importación, requisitos y middleware
  • Crear una aplicación con createBrowserRouter, rutas anidadas y Outlet
  • Leer parámetros de la URL, navegar por código y no caer en la trampa de los enlaces relativos
  • Cargar datos antes de pintar con loader y saber exactamente cuándo se vuelven a ejecutar
  • Enseñar estados de carga con useNavigation
  • Guardar filtros y paginación en la URL con useSearchParams y <Form> de tipo GET
  • Enviar datos con action y <Form>, y mostrar los errores de validación de la API
  • Hacer cambios sin navegar con useFetcher
  • Colocar fronteras de error por ruta con ErrorBoundary y useRouteError
  • Proteger rutas con middleware y entender qué protege de verdad y qué no
  • Dividir el código por rutas con lazy y aplazar datos lentos con <Await>
  • Usar React Router 7 si tu proyecto no puede subir a React 19.2.7

🆕 Desde React 19.2.7. React Router 8 exige react y react-dom 19.2.7 o superior, y Node 22.22 o superior: en React 19.2.6, 19.1, 19.0 o 18 no se instala. Si usas una versión anterior: instala react-router@7.18.5, que admite React 18 o superior y Node 20 o superior. Casi todo este módulo funciona igual; las diferencias están en la sección 20.

💡 Consejo: React Router es una biblioteca externa con su propio número de versión: no sigue el de React. Las versiones de este módulo son React 19.3.1 y React Router 8.5.1. Todo el código se ha compilado y recorrido en un navegador con esas versiones. La aplicación EMPRESA se ha recorrido también con React Router 7.18.5 sobre React 19.1.10 y 18.3.1, y los ejercicios sobre React 19.1.10. Las diferencias que se citan se han comprobado en los .d.ts instalados y ejecutando la aplicación, no solo en la documentación.


1. Qué problema resuelve un router

Una aplicación React de Vite es una sola página HTML: index.html. Sin embargo, los usuarios esperan cosas que nacen de tener muchas páginas:

  • Que cada pantalla tenga su propia dirección, para guardarla en favoritos o enviarla por correo
  • Que los botones Atrás y Adelante del navegador funcionen
  • Que al recargar sigas en la misma pantalla

Podrías cambiar de pantalla con un useState, pero la URL no cambiaría y perderías las tres cosas. Un router en el cliente resuelve esto: mantiene sincronizadas la URL y la interfaz.

Comparación entre una navegación clásica, en la que el servidor devuelve una página nueva, y el enrutado en el cliente, en el que el router cambia la URL sin recargar

Figura 1. En la navegación clásica cada enlace descarga una página nueva. Con un router, el navegador nunca abandona la página.

Por dentro se apoya en una API del navegador, la History API:

// Cambia la URL y añade una entrada al historial, sin pedir nada al servidor
history.pushState({}, '', '/empleados/100');

// Este evento se dispara cuando el usuario pulsa Atrás o Adelante
window.addEventListener('popstate', () => {
  console.log('Ahora estamos en', location.pathname);
});

Un router es, en esencia, tres piezas sobre esa API:

  1. Un componente de enlace que renderiza un <a> normal, intercepta el clic con preventDefault y llama a pushState.
  2. Un emparejador que compara la URL actual con tu lista de rutas.
  3. Un estado que hace que React vuelva a renderizar al cambiar la URL.

📘 Concepto: Una ruta es la asociación entre un patrón de URL (/empleados/:id) y lo que se debe mostrar y cargar. Una navegación es el paso de una ubicación a otra, ya sea por un enlace, por un envío de formulario o por los botones del navegador.

React no incluye nada de esto porque es una biblioteca de interfaz. Si necesitas enrutado tienes que elegir una biblioteca, o bien un framework que ya lo traiga, como Next.js (módulo 23). React Router es la opción más extendida para aplicaciones de una sola página, y es la que vas a aprender aquí.


2. Los tres modos de React Router

React Router se puede usar de tres maneras. No son productos distintos: el paquete es el mismo, y lo que cambia es la API con la que arrancas el router.

Tres cajas una dentro de otra: el modo Declarativo dentro del modo Datos, y este dentro del modo Framework

Figura 2. Cada modo añade funciones al anterior a cambio de ceder más control de la arquitectura.

  Declarativo Datos Framework
Arranque <BrowserRouter> createBrowserRouter + <RouterProvider> plugin de Vite reactRouter()
Dónde se definen las rutas JSX, con <Routes> Objetos JavaScript app/routes.ts y módulos de ruta
Link, useParams, Outlet Sí Sí Sí
loader, action, <Form> con envío al router No Sí Sí
useNavigation, useFetcher, ErrorBoundary No Sí Sí
middleware No Sí, en el cliente Sí, en cliente y servidor
SSR, prerenderizado No No Sí
Tipos generados por ruta No No Sí

¿Cuál eliges?

  • Datos si tienes una aplicación Vite de una sola página que consume una API. Es el caso de EMPRESA y el modo que se usa en casi todo el módulo.
  • Declarativo si ya gestionas los datos con otra herramienta, como TanStack Query, y solo quieres emparejar URL con pantallas, o si migras código antiguo.
  • Framework si quieres renderizado en el servidor, o que la herramienta te dé la división de código y los tipos sin configuración.

Los modos Declarativo y Framework se resumen en la sección 18.


3. React Router 8: instalación y qué ha cambiado

Instalar el paquete correcto

Solo hay que instalar un paquete:

npm install react-router

Fíjate en lo que no se instala: react-router-dom. Durante años fue el paquete de React Router para la web, y media Internet sigue diciendo que hay que instalarlo. En la versión 8 se ha eliminado.

⚠️ Importante: react-router-dom no tiene versión 8. Su etiqueta latest en npm sigue siendo la 7.18.5, así que npm install react-router-dom no da error: instala tan tranquilo la versión 7, y esa arrastra a su vez react-router 7. Si lo añades a un proyecto con react-router 8 acabas con dos routers distintos. Si ves react-router-dom en un tutorial, ese tutorial es de la versión 7 o anterior.

Requisitos

Lo que dice el package.json de react-router 8.5.1, instalado en el laboratorio:

Requisito React Router 8.5.1 React Router 7.18.5
react, react-dom >=19.2.7 >=18
Node >=22.22.0 >=20.0.0
Formato Solo módulos ES ES y CommonJS

Si intentas instalar la versión 8 en un proyecto con React 19.1.10, npm se detiene:

npm error code ERESOLVE
npm error ERESOLVE unable to resolve dependency tree
npm error Found: react@19.1.10
npm error peer react@">=19.2.7" from react-router@8.5.1

⚠️ Importante: Fíjate en que es una comparación de versiones, no de fechas. React 19.1.10 y 19.0.9 son parches de seguridad recientes, pero como número son menores que 19.2.7. Por tanto, 19.1.10 no sirve y 19.2.7, 19.2.9 o 19.3.1 sí.

Dónde se importa cada cosa

Hay dos rutas de importación que debes conocer. El package.json de react-router 8.5.1 publica ., ./dom, ./route-pattern, ./internal y ./internal/react-server-client. Las dos últimas son internas, y route-pattern solo exporta una función unstable_ que no necesitas.

Importación Qué contiene
react-router Todo lo habitual: createBrowserRouter, Link, NavLink, Outlet, Form, useLoaderData, redirect, data…
react-router/dom Solo lo que necesita react-dom: RouterProvider y HydratedRouter

También existe un RouterProvider en react-router. La diferencia se ve en el código fuente: el de react-router/dom es un envoltorio de una sola línea que le pasa flushSync de react-dom. El de react-router no lo recibe y sirve para entornos sin DOM. En una aplicación web importa siempre el de react-router/dom:

import { createBrowserRouter } from 'react-router';
import { RouterProvider } from 'react-router/dom';

Qué cambia respecto a la versión 7

Si vienes de la versión 7, o estás leyendo documentación de esa versión, esta tabla resume las diferencias que te afectan en modo Datos. Salen del CHANGELOG.md del paquete y se han comprobado en los tipos.

Tema React Router 7 React Router 8
Paquete react-router-dom Existe, reexporta react-router y react-router/dom Eliminado
Importar RouterProvider react-router-dom o react-router/dom Solo react-router/dom (o el de react-router, sin flushSync)
middleware La documentación pide future.v8_middleware; en modo Datos se ejecuta igualmente (ver sección 20) Siempre activo; el indicador ya no existe
Tipo de context en loader any si no amplías interface Future Siempre RouterContextProvider
FutureConfig Muchos indicadores v8_* Sin indicadores v8_*: pasarlos es un error de TypeScript
useMatches() loaderData y data obsoleto Solo loaderData
Propiedad hasErrorBoundary en rutas Aceptada Eliminada
Node, React Node 20, React 18 Node 22.22, React 19.2.7
Paquete ES y CommonJS Solo módulos ES

Lo importante es lo que no cambia: createBrowserRouter, Outlet, loader, action, Form, useFetcher, useNavigation, useSearchParams y las fronteras de error se usan igual. Del 7 al 8 no hay una reescritura, sino una limpieza.

Hay una novedad de la versión 8.5 que afecta a otro módulo: la API de transiciones de vista del router (viewTransition, useViewTransitionState) queda obsoleta en favor de <ViewTransition> de React. Lo verás en la sección 17.


4. Tu primera aplicación con rutas

Crea un proyecto como en el módulo 01, donde también se explica qué cambia si usas PowerShell:

npm create vite@latest modulo-18 -- --template react-ts
cd modulo-18
npm install
npm install react-router

Borra src/App.tsx y src/App.css, y sustituye src/index.css por estas reglas, que usan todos los ejemplos del módulo:

:root {
  font-family: 'Segoe UI', system-ui, sans-serif;
  color: #263238;
  background: #f5f6fa;
}

body {
  margin: 0;
}

.cabecera {
  display: flex;
  align-items: center;
  gap: 1rem;
  padding: 0.6rem 1.25rem;
  background: #1a237e;
  color: #fff;
}

.cabecera strong {
  letter-spacing: 0.04em;
}

.cabecera nav {
  display: flex;
  gap: 0.25rem;
  flex: 1;
}

.cabecera a {
  color: #c5cae9;
  text-decoration: none;
  padding: 0.3rem 0.7rem;
  border-radius: 6px;
}

.cabecera a.active {
  background: #3f51b5;
  color: #fff;
}

.cabecera a.pending {
  outline: 2px dashed #ff7043;
}

.cabecera form {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  margin: 0;
}

.barra-carga {
  height: 3px;
  background: transparent;
}

.barra-carga.activa {
  background: #ff7043;
}

main {
  padding: 1.25rem;
  max-width: 820px;
  transition: opacity 0.15s;
}

main[aria-busy='true'] {
  opacity: 0.55;
}

h1 {
  margin: 0 0 0.75rem;
  font-size: 1.4rem;
  color: #1a237e;
}

.tarjeta {
  background: #fff;
  border: 1px solid #c5cae9;
  border-radius: 10px;
  padding: 1rem 1.25rem;
  margin: 0 0 1rem;
}

.tarjeta h2 {
  margin: 0 0 0.5rem;
  font-size: 1.1rem;
  color: #1a237e;
}

.fila {
  display: flex;
  gap: 0.5rem;
  align-items: center;
  flex-wrap: wrap;
  margin-bottom: 0.75rem;
}

.dos-columnas {
  display: grid;
  grid-template-columns: 220px 1fr;
  gap: 1rem;
  align-items: start;
}

.lateral {
  list-style: none;
  margin: 0;
  padding: 0;
}

.lateral a {
  display: block;
  padding: 0.35rem 0.6rem;
  border-radius: 6px;
  color: #283593;
  text-decoration: none;
}

.lateral a.active {
  background: #3f51b5;
  color: #fff;
}

table {
  width: 100%;
  border-collapse: collapse;
  background: #fff;
}

th,
td {
  text-align: left;
  padding: 0.35rem 0.6rem;
  border-bottom: 1px solid #eceff1;
}

th {
  color: #1a237e;
}

button,
.boton {
  font: inherit;
  padding: 0.3rem 0.75rem;
  border: 1px solid #3f51b5;
  border-radius: 6px;
  background: #fff;
  color: #283593;
  cursor: pointer;
  text-decoration: none;
}

button:disabled {
  opacity: 0.5;
  cursor: wait;
}

button[aria-pressed='true'] {
  background: #3f51b5;
  color: #fff;
}

input,
select {
  font: inherit;
  padding: 0.3rem 0.5rem;
  border: 1px solid #9fa8da;
  border-radius: 6px;
}

.campo {
  display: grid;
  gap: 0.15rem;
  margin-bottom: 0.6rem;
}

.campo label {
  font-size: 0.9rem;
  color: #455a64;
}

.error {
  color: #c62828;
  font-size: 0.9rem;
}

.aviso {
  background: #fff3e0;
  border: 1px solid #ff7043;
  border-radius: 8px;
  padding: 0.5rem 0.75rem;
  margin-bottom: 0.75rem;
}

.cifras {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 0.75rem;
}

.cifras b {
  display: block;
  font-size: 1.5rem;
  color: #1a237e;
}

.migas {
  list-style: none;
  display: flex;
  gap: 0.4rem;
  padding: 0;
  margin: 0 0 0.75rem;
  color: #546e7a;
}

.migas li + li::before {
  content: '/';
  margin-right: 0.4rem;
}

Sustituye src/main.tsx. Este fichero ya no cambiará en todo el módulo:

import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { createBrowserRouter } from 'react-router';
import { RouterProvider } from 'react-router/dom';
import './index.css';
import { rutas } from './router';

const router = createBrowserRouter(rutas);

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <RouterProvider router={router} />
  </StrictMode>,
);

Y crea src/router.tsx, con unos datos de prueba para no depender todavía de la API:

import { Link, NavLink, Outlet, useParams, type RouteObject } from 'react-router';

// Datos de prueba: aún no hay API.
const departamentos = [
  { deptId: 10, nombre: 'DIRECCIÓN', localidad: 'Madrid' },
  { deptId: 20, nombre: 'VENTAS', localidad: 'Barcelona' },
  { deptId: 30, nombre: 'INFORMÁTICA', localidad: 'Valencia' },
];

function Layout() {
  return (
    <>
      <header className="cabecera">
        <strong>EMPRESA</strong>
        <nav aria-label="Principal">
          <NavLink to="/" end>
            Inicio
          </NavLink>
          <NavLink to="/departamentos">Departamentos</NavLink>
          <NavLink to="/acerca">Acerca de</NavLink>
        </nav>
      </header>
      <main>
        <Outlet />
      </main>
    </>
  );
}

function Inicio() {
  return (
    <>
      <h1>Inicio</h1>
      <p>
        Bienvenido a la intranet. Empieza por los{' '}
        <Link to="/departamentos">departamentos</Link>.
      </p>
    </>
  );
}

function Acerca() {
  return <h1>Acerca de esta aplicación</h1>;
}

function Departamentos() {
  return (
    <>
      <h1>Departamentos</h1>
      <div className="dos-columnas">
        <ul className="lateral">
          {departamentos.map((d) => (
            <li key={d.deptId}>
              <NavLink to={String(d.deptId)}>{d.nombre}</NavLink>
            </li>
          ))}
        </ul>
        <Outlet />
      </div>
    </>
  );
}

function SinSeleccion() {
  return <p>Elige un departamento de la lista.</p>;
}

function DepartamentoDetalle() {
  const { id } = useParams();
  const departamento = departamentos.find((d) => String(d.deptId) === id);

  if (!departamento) {
    return <p className="error">No existe el departamento {id}.</p>;
  }
  return (
    <section className="tarjeta">
      <h2>{departamento.nombre}</h2>
      <p>Sede de {departamento.localidad}</p>
    </section>
  );
}

function NoEncontrada() {
  return <h1>Esa página no existe</h1>;
}

export const rutas: RouteObject[] = [
  {
    path: '/',
    Component: Layout,
    children: [
      { index: true, Component: Inicio },
      { path: 'acerca', Component: Acerca },
      {
        path: 'departamentos',
        Component: Departamentos,
        children: [
          { index: true, Component: SinSeleccion },
          { path: ':id', Component: DepartamentoDetalle },
        ],
      },
      { path: '*', Component: NoEncontrada },
    ],
  },
];

Arranca con npm run dev y abre la dirección que te indique Vite. Pulsa en Departamentos y luego en VENTAS:

Aplicación con una cabecera azul, una lista lateral de departamentos con VENTAS resaltado y la ficha de ese departamento a la derecha

Figura 3. La cabecera viene de Layout, la lista de Departamentos y la ficha de DepartamentoDetalle. Son tres rutas activas a la vez.

Abre las herramientas de desarrollo en la pestaña Red y navega por los enlaces: no aparece ninguna petición de documento. Pulsa Atrás y funciona. Recarga en /departamentos/20 y sigues en VENTAS.

Repasemos las piezas:

  • createBrowserRouter(rutas) crea el router a partir de un array de objetos de ruta. Se llama fuera de los componentes y una sola vez: si lo creas dentro de un componente, cada render crea un router nuevo y pierdes el estado de la navegación.
  • <RouterProvider> conecta ese router con React.
  • Component indica qué componente pinta la ruta. También existe element, que recibe JSX ya creado (element: <Inicio />). Son equivalentes; usa una u otra, nunca las dos en la misma ruta.
  • <Link to> sustituye a <a href>. Si usas un <a> normal, el enlace funciona, pero recarga toda la página y pierdes el estado de JavaScript.
  • <NavLink> es un Link que sabe si está activo: añade la clase active y el atributo aria-current="page". Por eso en el CSS hay una regla .cabecera a.active.
  • end en el NavLink de inicio es necesario porque toda URL empieza por /. Sin él, Inicio estaría siempre activo.
  • path: '*' es la ruta comodín. Recoge cualquier URL que no encaje con otra, y sirve para la página 404.

💡 Consejo: NavLink también admite funciones en className, style y children, que reciben { isActive, isPending }. Sirven cuando no quieres depender de la clase active:

<NavLink to="/empleados" className={({ isActive }) => (isActive ? 'marcado' : '')}>
  Empleados
</NavLink>

5. Rutas anidadas y Outlet

Una ruta con children forma un árbol. Cuando llega una URL, el router busca la rama completa que encaja y activa todos sus niveles, del padre al hijo. Cada padre decide dónde va su hijo colocando un <Outlet />.

A la izquierda, el árbol de tres rutas que encaja con /departamentos/20. A la derecha, los tres componentes anidados unos dentro de otros mediante Outlet

Figura 4. La anidación de las rutas es también la anidación visual: cada nivel se pinta en el Outlet de su padre.

Esta es la idea que distingue a React Router de una simple tabla de URL: el diseño compartido no se repite en cada página, sino que pertenece a una ruta padre. Cuando vas de VENTAS a DIRECCIÓN, Layout y Departamentos siguen montados y conservan su estado. Solo cambia lo que hay en el último Outlet.

Rutas índice

En /departamentos la ruta padre encaja, pero ninguna hija. ¿Qué se pinta en su Outlet? Una ruta índice, marcada con index: true, que no lleva path y es la hija por defecto:

{
  path: 'departamentos',
  Component: Departamentos,
  children: [
    { index: true, Component: SinSeleccion }, // /departamentos
    { path: ':id', Component: DepartamentoDetalle }, // /departamentos/20
  ],
},

Sin ella el Outlet no renderizaría nada, y el usuario vería un hueco en blanco.

Rutas sin path

Una ruta puede no tener path. No añade nada a la URL, pero sí un nivel al árbol. Sirve para agrupar: darle un diseño, una frontera de error o un middleware comunes a varias hermanas.

{
  // Sin path: /perfil y /ajustes comparten el marco, pero no un prefijo de URL
  Component: MarcoPrivado,
  children: [
    { path: 'perfil', Component: Perfil },
    { path: 'ajustes', Component: Ajustes },
  ],
},

También puede ocurrir lo contrario, una ruta con path y sin componente. Entonces solo agrupa por prefijo, y sus hijas se pintan directamente en el Outlet del nivel superior.

Qué ruta gana

El orden del array casi no importa, porque el router puntúa las rutas según lo específicas que son. Una parte fija gana a un parámetro, y un parámetro gana al comodín. Por eso estas dos conviven sin problema, y /empleados/nuevo no se interpreta como el empleado de id nuevo:

{ path: 'empleados/:id', Component: Detalle },
{ path: 'empleados/nuevo', Component: Nuevo },

Pasar datos a la ruta hija

El Outlet admite una prop context, que la hija lee con useOutletContext:

// Padre
<Outlet context={{ departamentos }} />

// Hija
const { departamentos } = useOutletContext<{ departamentos: Departamento[] }>();

La trampa de los enlaces relativos

Si un to no empieza por /, es relativo. Lo que sorprende es a qué es relativo: por defecto, a la jerarquía de rutas, no a la URL.

Lo vimos al probar este módulo. Con esta ruta:

{ path: 'empleados/:id', Component: Detalle }, // hija directa de '/'

y en /empleados/100 este enlace:

<Link to="..">Volver</Link>

Pensarías que lleva a /empleados. Pero lleva a /. .. significa “la ruta padre”, y el padre de esa ruta es /, porque empleados/:id es una sola ruta, aunque la URL tenga dos segmentos. Tienes dos soluciones:

// Quitar un segmento de la URL en lugar de subir una ruta
<Link to=".." relative="path">Volver</Link>

// O escribir la ruta absoluta
<Link to="/empleados">Volver</Link>

⚠️ Importante: relative="path" hace que .. se comporte como en un sistema de ficheros. En cambio, to="editar" sí funciona sin más en esa misma ruta y lleva a /empleados/100/editar, porque sumar a la ruta actual no tiene la ambigüedad de subir un nivel.


6. Parámetros y navegación

Leer parámetros con useParams

Un segmento que empieza por : es un parámetro dinámico:

const { id } = useParams();

Debes tener en cuenta dos cosas. Los parámetros siempre son cadenas, o sea, '20' y no 20: por eso el ejemplo compara con String(d.deptId) === id. Y TypeScript los tipa como string | undefined, ya que un componente no sabe en qué ruta lo vas a colocar. Además, el usuario puede escribir lo que quiera en la barra de direcciones, así que valida siempre: la URL es una entrada externa, como un formulario.

Esto es lo que devuelve matchPath, la función con la que el router empareja. Es una salida real:

matchPath('/empleados/:id', '/empleados/100')              params: {"id":"100"}
matchPath('/empleados/:id', '/empleados/100/editar')       null
matchPath('/:idioma?/empleados', '/empleados')             params: {}
matchPath('/:idioma?/empleados', '/es/empleados')          params: {"idioma":"es"}
matchPath('/ficheros/*', '/ficheros/2026/informe.pdf')     params: {"*":"2026/informe.pdf"}
generatePath('/empleados/:id/editar', { id: '100' })       /empleados/100/editar
generatePath('/buscar/:termino', { termino: 'año nuevo' }) /buscar/a%C3%B1o%20nuevo
  • Un parámetro opcional lleva ?: /:idioma?/empleados encaja con y sin idioma.
  • Un comodín final * recoge el resto de la URL en params['*'].
  • generatePath hace lo contrario: construye la URL a partir del patrón, y codifica los caracteres especiales.

<Link> es lo normal, pero a veces la navegación no nace de un clic en un enlace. Para eso están useNavigate y <Navigate>:

const navegar = useNavigate();

navegar('/empleados'); // Va a una ruta
navegar(-1); // Equivale a pulsar Atrás
navegar('/login', { replace: true }); // Sustituye la entrada del historial
navegar('/gracias', { state: { pedido: 5001 } }); // Datos que no van en la URL
// Redirige en cuanto se renderiza
if (!permitido) return <Navigate to="/" replace />;

La opción replace merece una explicación. Normalmente cada navegación añade una entrada al historial. Con replace, la nueva sustituye a la actual, y Atrás no vuelve a ella. Úsala tras un inicio de sesión o una redirección automática: si no, Atrás devuelve al usuario a una pantalla que lo redirige otra vez, y queda atrapado.

💡 Consejo: Antes de escribir useNavigate dentro de un onClick, pregúntate si no debería ser un <Link>. Un enlace de verdad se puede abrir en otra pestaña, lo anuncia correctamente un lector de pantalla y funciona con el teclado sin código extra. Reserva useNavigate para lo que ocurre después de algo, y prefiere devolver redirect en una action, como verás en la sección 11.

Saber dónde estás con useLocation

useLocation devuelve la ubicación actual, y su identidad cambia con cada navegación:

const { pathname, search, hash, state, key } = useLocation();

state son los datos que pasaste al navegar. Se guardan en el historial del navegador, por lo que sobreviven a una recarga, pero no se comparten al copiar la URL.


7. Prepara la aplicación EMPRESA

Hasta aquí los datos estaban escritos a mano. Desde ahora trabajarás contra la API. Descarga la API de ejemplo y arráncala en otra terminal con un pequeño retardo, para que los estados de carga se vean:

RETARDO=300 node server.mjs

En PowerShell son dos órdenes: $env:RETARDO=300; node server.mjs. La API queda en http://localhost:3000/api.

Crea primero los tipos, en src/tipos.ts:

export interface Empleado {
  empId: number;
  nombre: string;
  apellidos: string;
  email: string;
  telefono: string | null;
  fechaAlta: string;
  oficio: string;
  salario: number;
  comision: number | null;
  jefeId: number | null;
  deptId: number;
  departamento: string;
}

export interface Departamento {
  deptId: number;
  nombre: string;
  localidad: string;
  presupuesto: number;
}

export interface Pagina<T> {
  contenido: T[];
  pagina: number;
  tamano: number;
  total: number;
  paginas: number;
}

export interface Usuario {
  email: string;
  nombre: string;
  rol: 'ADMIN' | 'USER';
}

export interface Sesion {
  token: string;
  usuario: Usuario;
}

export interface Estadisticas {
  empleados: number;
  departamentos: number;
  clientes: number;
  pedidos: number;
  facturacion: number;
}

La sesión se guarda en localStorage, en src/sesion.ts. Es un fichero aparte porque lo usarán tanto el cliente de la API como el login:

import type { Sesion } from './tipos';

const CLAVE = 'empresa.sesion';

export function leerSesion(): Sesion | null {
  const texto = localStorage.getItem(CLAVE);
  return texto ? (JSON.parse(texto) as Sesion) : null;
}

export function guardarSesion(sesion: Sesion) {
  localStorage.setItem(CLAVE, JSON.stringify(sesion));
}

export function borrarSesion() {
  localStorage.removeItem(CLAVE);
}

Y una función para hablar con la API, en src/api.ts:

import { redirect } from 'react-router';
import { borrarSesion, leerSesion } from './sesion';

const API = import.meta.env.VITE_API ?? 'http://localhost:3000/api';

export class ErrorApi extends Error {
  status: number;
  detalles?: Record<string, string>;

  constructor(status: number, mensaje: string, detalles?: Record<string, string>) {
    super(mensaje);
    this.name = 'ErrorApi';
    this.status = status;
    this.detalles = detalles;
  }
}

interface Opciones {
  metodo?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
  cuerpo?: unknown;
  signal?: AbortSignal;
}

export async function pedir<T>(ruta: string, opciones: Opciones = {}): Promise<T> {
  const { metodo = 'GET', cuerpo, signal } = opciones;
  const sesion = leerSesion();

  const respuesta = await fetch(`${API}${ruta}`, {
    method: metodo,
    signal,
    headers: {
      ...(cuerpo !== undefined && { 'Content-Type': 'application/json' }),
      ...(sesion && { Authorization: `Bearer ${sesion.token}` }),
    },
    body: cuerpo === undefined ? undefined : JSON.stringify(cuerpo),
  });

  // Un 401 con sesión guardada significa que el token ha caducado.
  if (respuesta.status === 401 && sesion) {
    borrarSesion();
    throw redirect('/login?caducada=1');
  }

  if (!respuesta.ok) {
    const error = await respuesta.json();
    throw new ErrorApi(respuesta.status, error.mensaje, error.detalles);
  }

  return respuesta.status === 204 ? (undefined as T) : respuesta.json();
}

Observa tres decisiones que usaremos después:

  • pedir acepta un signal, para poder cancelar la petición si el usuario se va a otra pantalla.
  • Si la respuesta no es correcta, lanza un ErrorApi con el código, el mensaje y los detalles de validación que devuelve la API.
  • Si llega un 401 con sesión guardada, lanza un redirect. Es un detalle propio de React Router: redirect devuelve un objeto Response, y un loader o una action lo pueden lanzar con throw desde cualquier profundidad.

💡 Consejo: VITE_API es una variable de entorno opcional de Vite. Si no la defines, se usa la dirección de la API de ejemplo. Lo verás con calma en el módulo 25.


8. Cargar datos con loader

El problema de cargar en un efecto

Ya sabes cargar datos con useEffect (módulo 09). El patrón funciona, pero tiene un defecto de orden:

  1. La URL cambia.
  2. Se pinta el componente nuevo, vacío o con un “Cargando…”.
  3. Se ejecuta el efecto y sale la petición.
  4. Llegan los datos y se vuelve a pintar.

Es decir, primero se pinta y después se carga. Con rutas anidadas esto empeora: el hijo no empieza a pedir sus datos hasta que el padre ha terminado de pintarse, y aparecen las cascadas que viste en el módulo 15. Además, cada componente acumula sus estados cargando y error, y su propia gestión de las condiciones de carrera.

Un loader invierte el orden. Es una función asociada a la ruta que el router ejecuta antes de renderizar. Como conoce las rutas de antemano, puede lanzar a la vez los loader de todos los niveles.

Dos filas de pasos. La de GET va de clic a middleware, loaders, cambio de URL y render. La de POST va de envío a middleware, action, redirección, loaders y render

Figura 5. El ciclo completo de una navegación. Durante la carga, la pantalla antigua sigue visible.

Tu primer loader

Crea src/rutas/empleado.tsx. A partir de ahora, cada fichero de src/rutas/ exporta su loader, su action y su Component:

import { data, Link, useLoaderData, type LoaderFunctionArgs } from 'react-router';
import { pedir } from '../api';
import type { Empleado } from '../tipos';

export async function loader({ params, request }: LoaderFunctionArgs) {
  if (!/^\d+$/.test(params.id ?? '')) {
    throw data('El identificador debe ser un número', { status: 400 });
  }
  const empleado = await pedir<Empleado>(`/empleados/${params.id}`, {
    signal: request.signal,
  });
  return { empleado };
}

export function Component() {
  const { empleado } = useLoaderData<typeof loader>();

  return (
    <article className="tarjeta">
      <h1>
        {empleado.nombre} {empleado.apellidos}
      </h1>
      <p>
        {empleado.oficio} en <Link to={`/departamentos/${empleado.deptId}`}>{empleado.departamento}</Link>
      </p>
      <p>
        <a href={`mailto:${empleado.email}`}>{empleado.email}</a>, alta el {empleado.fechaAlta}
      </p>
      <p className="fila">
        <Link className="boton" to=".." relative="path">
          Volver
        </Link>
        <Link className="boton" to="editar">
          Editar
        </Link>
      </p>
    </article>
  );
}

Y se registra en la ruta:

import * as Empleado from './rutas/empleado';

{ path: 'empleados/:id', loader: Empleado.loader, Component: Empleado.Component },

Fíjate en lo que no hay en el componente: ni useState, ni useEffect, ni un if (cargando). Cuando Component se ejecuta, los datos ya están ahí.

  • El loader recibe un objeto con params, request, context, url y pattern. pattern es el patrón sin rellenar, como /empleados/:id, útil para los registros.
  • useLoaderData<typeof loader>() devuelve lo que devolvió el loader. El genérico typeof loader hace que TypeScript deduzca el tipo desde la función, y así no lo tienes que repetir.
  • request es un objeto Request estándar. Con request.signal la petición se cancela si el usuario se va antes de que termine.

⚠️ Importante: useLoaderData<typeof loader>() no comprueba nada al ejecutarse. Es una afirmación tuya sobre el tipo. Si pones el genérico de un loader que no es el de esa ruta, TypeScript no se quejará. Por eso conviene que el loader y el componente vivan en el mismo fichero.

Qué se ejecuta y cuándo: el experimento

Para saber de verdad en qué orden ocurre todo, usa este router.tsx, que escribe un mensaje en cada paso. El loader del padre tarda 100 ms y el del hijo 300 ms:

import {
  Form,
  Link,
  Outlet,
  redirect,
  useLoaderData,
  type RouteObject,
} from 'react-router';

const espera = (ms: number) => new Promise((resolver) => setTimeout(resolver, ms));

function Padre() {
  const { hora } = useLoaderData<{ hora: string }>();
  console.log('render Padre');
  return (
    <>
      <nav className="fila">
        <Link to="/hijo/1">Hijo 1</Link>
        <Link to="/hijo/2">Hijo 2</Link>
        <Link to="/otra">Otra</Link>
        <Link to="?filtro=1">Con filtro</Link>
      </nav>
      <p>Padre cargado a las {hora}</p>
      <Outlet />
    </>
  );
}

function Hijo() {
  const { id } = useLoaderData<{ id: string }>();
  console.log('render Hijo', id);
  return (
    <Form method="post">
      <p>Hijo {id}</p>
      <button>Enviar</button>
    </Form>
  );
}

function Otra() {
  console.log('render Otra');
  return <p>Otra</p>;
}

export const rutas: RouteObject[] = [
  {
    path: '/',
    Component: Padre,
    middleware: [
      async (_args, next) => {
        console.log('middleware padre: entra');
        await next();
        console.log('middleware padre: sale');
      },
    ],
    async loader() {
      console.log('loader padre: empieza');
      await espera(100);
      console.log('loader padre: termina');
      return { hora: new Date().toLocaleTimeString('es-ES') };
    },
    children: [
      {
        path: 'hijo/:id',
        Component: Hijo,
        middleware: [
          async (_args, next) => {
            console.log('middleware hijo: entra');
            await next();
            console.log('middleware hijo: sale');
          },
        ],
        async loader({ params, request }) {
          console.log('loader hijo: empieza', params.id);
          request.signal.addEventListener('abort', () => {
            console.log('loader hijo: cancelado', params.id);
          });
          await espera(300);
          console.log('loader hijo: termina', params.id);
          return { id: params.id };
        },
        async action({ params }) {
          console.log('action hijo', params.id);
          await espera(50);
          return redirect('/otra');
        },
      },
      { path: 'otra', Component: Otra },
    ],
  },
];

Esta es la consola real al abrir directamente /hijo/1. Los render salen dos veces por Strict Mode (módulo 05):

middleware padre: entra
middleware hijo: entra
loader padre: empieza
loader hijo: empieza 1
loader padre: termina
loader hijo: termina 1
middleware hijo: sale
middleware padre: sale
render Padre
render Padre
render Hijo 1
render Hijo 1

Los dos loader empiezan a la vez: no hay cascada. Y ningún componente se renderiza hasta que han terminado todos. Ahora pulsa en Hijo 2:

middleware padre: entra
middleware hijo: entra
loader hijo: empieza 2
loader hijo: termina 2
middleware hijo: sale
middleware padre: sale
render Padre
render Padre
render Hijo 2
render Hijo 2

El loader del padre no se ha ejecutado. Su ruta sigue activa con los mismos parámetros, de modo que el router reutiliza sus datos. Es lo que hace eficientes las rutas anidadas. El componente Padre sí se renderiza, pero eso es barato comparado con una petición de red.

Ahora pulsa Con filtro, que añade ?filtro=1 a la misma URL:

middleware padre: entra
middleware hijo: entra
loader padre: empieza
loader hijo: empieza 2
loader padre: termina
loader hijo: termina 2
middleware hijo: sale
middleware padre: sale
render Padre
render Padre
render Hijo 2
render Hijo 2

Cambiar la búsqueda de la URL vuelve a ejecutar todos los loader activos, incluido el del padre. El router no sabe cuáles leen esos parámetros, así que por seguridad repite todos.

Por último, pulsa dos enlaces seguidos: Hijo 2 y, 50 ms después, Otra:

middleware padre: entra
middleware hijo: entra
loader hijo: empieza 2
loader hijo: cancelado 2
middleware padre: entra
middleware padre: sale
middleware hijo: sale
middleware padre: sale
render Padre
render Padre
render Otra
render Otra
loader hijo: termina 2

La segunda navegación cancela la primera: request.signal dispara abort. Se acabaron las condiciones de carrera del módulo 09, que aquí te las resuelve el router. Mira la última línea: la función no se detiene sola por haber sido cancelada, porque nuestro setTimeout sigue su curso. Su resultado se descarta. Por eso debes pasar signal a fetch: si no lo haces, la petición de red sigue adelante para nada.

Reglas de la revalidación

Resumen de lo que acabas de medir:

Qué ocurre ¿Se ejecuta el loader?
La ruta entra en pantalla Sí
La ruta sigue activa y sus params cambian Sí
La ruta sigue activa y sus params no cambian No
Cambia la búsqueda de la URL (?filtro=1) Sí, todos los activos
Termina una action (sección 11) Sí, todos los activos
Vuelves a la misma URL con <Link> Sí, todos los activos

Si esto se queda corto o se pasa, dos herramientas lo ajustan:

// Decide si se repite este loader: por ejemplo, ignora los cambios de ?pagina
{
  path: 'empleados',
  loader: cargarEmpleados,
  shouldRevalidate: ({ currentUrl, nextUrl, defaultShouldRevalidate }) =>
    currentUrl.pathname === nextUrl.pathname ? false : defaultShouldRevalidate,
}
// Pide repetir los loader a mano, por ejemplo con un botón "Actualizar"
const { revalidate, state } = useRevalidator();

Datos de una ruta padre

useLoaderData solo ve el loader de su ruta. Para leer el de otra ruta activa, dale un id a esa ruta y usa useRouteLoaderData:

{ id: 'raiz', path: '/', loader: Raiz.loader, /* ... */ }
const raiz = useRouteLoaderData<typeof cargarRaiz>('raiz');

Puede devolver undefined si esa ruta no está activa, y por eso se escribe raiz?.usuario.

La primera carga: HydrateFallback

En una navegación hay una pantalla antigua que mantener, pero al abrir la aplicación no hay ninguna. Si la ruta tiene loader, el router necesita algo que mostrar mientras tanto: HydrateFallback. Si no lo pones, la pantalla queda en blanco y aparece este aviso en la consola:

No `HydrateFallback` element provided to render during initial hydration

Basta con declararlo en la ruta raíz:

{ path: '/', HydrateFallback: () => <p>Cargando la aplicación…</p>, /* ... */ }

9. Estados de carga con useNavigation

Como la pantalla antigua sigue visible durante la navegación, si la API tarda el usuario pulsa y no parece pasar nada. useNavigation te dice qué está haciendo el router:

state Significado
'idle' No hay navegación en curso
'loading' Se están ejecutando los loader de la siguiente pantalla
'submitting' Se está enviando un formulario a una action

Mientras no es 'idle', tienes además navigation.location, con el destino, y navigation.formData, con lo que se está enviando. Crea el diseño de la aplicación en src/rutas/raiz.tsx:

import { useEffect } from 'react';
import {
  Form,
  NavLink,
  Outlet,
  ScrollRestoration,
  useLoaderData,
  useNavigation,
  type LoaderFunctionArgs,
} from 'react-router';
import { contextoSesion } from '../middleware';

export function loader({ context }: LoaderFunctionArgs) {
  return { usuario: context.get(contextoSesion)?.usuario ?? null };
}

export function Component() {
  const { usuario } = useLoaderData<typeof loader>();
  const navegacion = useNavigation();
  const ocupado = navegacion.state !== 'idle';

  useEffect(() => {
    document.title = ocupado ? 'Cargando…' : 'EMPRESA';
  }, [ocupado]);

  return (
    <>
      <header className="cabecera">
        <strong>EMPRESA</strong>
        <nav aria-label="Principal">
          <NavLink to="/" end>
            Inicio
          </NavLink>
          <NavLink to="/empleados">Empleados</NavLink>
          <NavLink to="/departamentos">Departamentos</NavLink>
        </nav>
        {usuario ? (
          <Form method="post" action="/logout">
            <span>
              {usuario.nombre} ({usuario.rol})
            </span>
            <button>Salir</button>
          </Form>
        ) : (
          <NavLink to="/login">Entrar</NavLink>
        )}
      </header>
      <div className={ocupado ? 'barra-carga activa' : 'barra-carga'} />
      <main aria-busy={ocupado}>
        <Outlet />
      </main>
      <ScrollRestoration />
    </>
  );
}

export function HydrateFallback() {
  return <p>Cargando la aplicación…</p>;
}

Este loader lee el usuario de context. Se explica en la sección 14: de momento, quédate con que devuelve { usuario }, y con el uso de useNavigation:

La lista de empleados atenuada, con una línea naranja bajo la cabecera y el enlace Departamentos con un borde discontinuo mientras se carga la página siguiente

Figura 6. Navegación pendiente: la pantalla antigua se atenúa, la barra naranja se enciende y el NavLink de destino recibe la clase pending.

Tres detalles de calidad:

  • aria-busy en <main> anuncia a los lectores de pantalla que la zona está cambiando.
  • El NavLink hacia el destino recibe la clase pending mientras carga, aunque no hayas escrito nada. La regla .cabecera a.pending del CSS la aprovecha.
  • <ScrollRestoration /> lleva el desplazamiento vertical arriba al navegar, y restaura la posición al pulsar Atrás. Basta con ponerlo una vez en la ruta raíz.

📘 Concepto: Por defecto, el router envuelve sus cambios de estado en startTransition (módulo 15). <RouterProvider> tiene una prop useTransitions para decidirlo: con false no usa transiciones y con true también envuelve las navegaciones de Link y Form, y las muestra con useOptimistic. Déjala sin definir salvo que estés integrando tus propias transiciones.


10. La URL como estado: useSearchParams

¿Dónde guardas el texto de un buscador y la página actual de una tabla? Tu primer impulso es useState. Pero entonces, al recargar se pierde, no lo puedes compartir por correo, y Atrás no deshace el filtro.

Si un dato define qué está viendo el usuario, su sitio es la URL. /empleados?q=garcia&pagina=2 es una pantalla que se puede guardar, enviar y recargar.

💻 Ejemplo: Crea src/rutas/empleados.tsx, con buscador, orden, paginación y borrado:

import {
  Form,
  Link,
  useFetcher,
  useLoaderData,
  useNavigation,
  useRouteLoaderData,
  useSearchParams,
  type LoaderFunctionArgs,
} from 'react-router';
import { pedir } from '../api';
import type { loader as cargarRaiz } from './raiz';
import type { Empleado, Pagina } from '../tipos';

export async function loader({ request, url }: LoaderFunctionArgs) {
  const q = url.searchParams.get('q') ?? '';
  const orden = url.searchParams.get('orden') ?? 'apellidos';
  const pagina = url.searchParams.get('pagina') ?? '1';

  const consulta = new URLSearchParams({ q, orden, pagina, tamano: '6' });
  const resultado = await pedir<Pagina<Empleado>>(`/empleados?${consulta}`, {
    signal: request.signal,
  });
  return { resultado, q };
}

export function Component() {
  const { resultado, q } = useLoaderData<typeof loader>();
  const [parametros, setParametros] = useSearchParams();
  const navegacion = useNavigation();
  const raiz = useRouteLoaderData<typeof cargarRaiz>('raiz');

  // Hay búsqueda en curso si la navegación pendiente trae el parámetro q.
  const buscando = new URLSearchParams(navegacion.location?.search).has('q');

  function cambiarPagina(pagina: number) {
    setParametros((actuales) => {
      actuales.set('pagina', String(pagina));
      return actuales;
    });
  }

  return (
    <>
      <h1>Empleados ({resultado.total})</h1>

      <Form className="fila" role="search">
        <input
          key={q}
          type="search"
          name="q"
          defaultValue={q}
          placeholder="Nombre o apellidos"
          aria-label="Buscar empleados"
        />
        <select name="orden" defaultValue={parametros.get('orden') ?? 'apellidos'} aria-label="Orden">
          <option value="apellidos">Apellidos</option>
          <option value="-salario">Salario, de mayor a menor</option>
          <option value="fechaAlta">Antigüedad</option>
        </select>
        <button>{buscando ? 'Buscando…' : 'Buscar'}</button>
        {raiz?.usuario && (
          <Link className="boton" to="/empleados/nuevo">
            Nuevo empleado
          </Link>
        )}
      </Form>

      <table>
        <thead>
          <tr>
            <th>Empleado</th>
            <th>Departamento</th>
            <th>Oficio</th>
            <th aria-label="Acciones" />
          </tr>
        </thead>
        <tbody>
          {resultado.contenido.map((empleado) => (
            <FilaEmpleado
              key={empleado.empId}
              empleado={empleado}
              esAdmin={raiz?.usuario?.rol === 'ADMIN'}
            />
          ))}
        </tbody>
      </table>

      <p className="fila">
        <button
          disabled={resultado.pagina <= 1}
          onClick={() => cambiarPagina(resultado.pagina - 1)}
        >
          Anterior
        </button>
        Página {resultado.pagina} de {resultado.paginas}
        <button
          disabled={resultado.pagina >= resultado.paginas}
          onClick={() => cambiarPagina(resultado.pagina + 1)}
        >
          Siguiente
        </button>
      </p>
    </>
  );
}

interface PropsFila {
  empleado: Empleado;
  esAdmin: boolean;
}

function FilaEmpleado({ empleado, esAdmin }: PropsFila) {
  const fetcher = useFetcher<{ error?: string }>();
  const borrando = fetcher.state !== 'idle';

  return (
    <tr style={{ opacity: borrando ? 0.4 : 1 }}>
      <td>
        <Link to={`/empleados/${empleado.empId}`}>
          {empleado.apellidos}, {empleado.nombre}
        </Link>
        {fetcher.data?.error && <div className="error">{fetcher.data.error}</div>}
      </td>
      <td>{empleado.departamento}</td>
      <td>{empleado.oficio}</td>
      <td>
        {esAdmin && (
          <fetcher.Form method="post" action={`/empleados/${empleado.empId}/borrar`}>
            <button disabled={borrando}>Borrar</button>
          </fetcher.Form>
        )}
      </td>
    </tr>
  );
}

Tabla de empleados ordenada por salario, página 2 de 4, con buscador, selector de orden y paginador

Figura 7. La tabla refleja /empleados?q=&orden=-salario&pagina=2. Si recargas esa URL, ves exactamente lo mismo.

Hay mucho en este fichero. Veámoslo por partes.

<Form> de tipo GET

<Form className="fila" role="search">

Un <Form> sin method es de tipo GET, igual que un <form> HTML: sus campos pasan a la URL. Al enviarlo, navegas a /empleados?q=garcia&orden=apellidos. No hay onSubmit, ni estado, ni preventDefault. La navegación activa el loader, que lee url.searchParams y pide los datos.

Se perciben aquí las ventajas de apoyarse en la plataforma. Atrás deshace la búsqueda, el campo se envía con Intro y la accesibilidad es la del formulario de toda la vida.

Por qué el campo lleva key={q}

Busca garcia y pulsa Atrás. La lista vuelve a mostrar todos, y la URL no tiene q. Pero si no tuviera key, el <input> seguiría diciendo garcia: un campo no controlado solo usa defaultValue al montarse. Con key={q}, al cambiar q React crea un campo nuevo (módulo 05) y el valor queda sincronizado con la URL. Se ha comprobado con el navegador: tras Atrás, el campo queda vacío.

useSearchParams

useSearchParams funciona como useState, pero sobre la búsqueda de la URL:

const [parametros, setParametros] = useSearchParams();

parametros.get('orden'); // string | null
setParametros({ orden: '-salario' }); // Sustituye TODOS los parámetros

El objeto es un URLSearchParams estándar. Aquí está la trampa: pasarle un objeto sustituye la búsqueda entera, y perderías q y orden al paginar. Para cambiar solo uno, usa la forma con función:

setParametros((actuales) => {
  actuales.set('pagina', String(pagina));
  return actuales;
});

Así la aplicación pasa de ?q=&orden=-salario a ?q=&orden=-salario&pagina=2, y conserva el resto.

⚠️ Importante: Todo lo que viene de la URL son cadenas, y el usuario puede escribir ?pagina=hola. Trátalo como entrada externa: valida o da un valor por defecto. Aquí la API se encarga, pero un Number(...) sin comprobar te puede dar NaN.

Cada setParametros añade una entrada al historial. Si no lo quieres, pasa la opción replace:

setParametros(nuevos, { replace: true });

Saber qué está cargando

const buscando = new URLSearchParams(navegacion.location?.search).has('q');

navegacion.location es el destino, así que puedes averiguar si la navegación en curso es una búsqueda, para escribir “Buscando…” solo en ese caso.


11. Enviar datos con action y <Form>

El loader lee y la action escribe. La idea viene directamente de los formularios HTML clásicos: envías un POST a una URL, el servidor hace el trabajo y te devuelve otra página. React Router reproduce ese modelo dentro del navegador.

Crea src/rutas/empleado-form.tsx, que sirve para crear y para editar:

import {
  Form,
  redirect,
  useActionData,
  useLoaderData,
  useNavigation,
  type ActionFunctionArgs,
  type LoaderFunctionArgs,
} from 'react-router';
import { ErrorApi, pedir } from '../api';
import type { Departamento, Empleado, Pagina } from '../tipos';

export async function loader({ params, request }: LoaderFunctionArgs) {
  const { signal } = request;
  // Las dos peticiones salen a la vez: el formulario necesita ambas.
  const [departamentos, empleado] = await Promise.all([
    pedir<Pagina<Departamento>>('/departamentos', { signal }),
    params.id ? pedir<Empleado>(`/empleados/${params.id}`, { signal }) : null,
  ]);
  return { departamentos: departamentos.contenido, empleado };
}

export async function action({ params, request }: ActionFunctionArgs) {
  const formulario = await request.formData();
  const cuerpo = {
    nombre: String(formulario.get('nombre')),
    apellidos: String(formulario.get('apellidos')),
    email: String(formulario.get('email')),
    oficio: String(formulario.get('oficio')),
    salario: Number(formulario.get('salario')),
    deptId: Number(formulario.get('deptId')),
  };

  try {
    const guardado = await pedir<Empleado>(
      params.id ? `/empleados/${params.id}` : '/empleados',
      { metodo: params.id ? 'PUT' : 'POST', cuerpo },
    );
    return redirect(`/empleados/${guardado.empId}`);
  } catch (error) {
    // Un 422 no es un fallo de la aplicación: se devuelve para pintarlo.
    if (error instanceof ErrorApi && error.status === 422) {
      return { errores: error.detalles ?? {} };
    }
    throw error;
  }
}

export function Component() {
  const { departamentos, empleado } = useLoaderData<typeof loader>();
  const resultado = useActionData<typeof action>();
  const navegacion = useNavigation();
  const errores = resultado?.errores;
  const guardando = navegacion.state === 'submitting';

  return (
    <Form method="post" className="tarjeta" noValidate>
      <h1>{empleado ? 'Editar empleado' : 'Nuevo empleado'}</h1>
      <Campo nombre="nombre" etiqueta="Nombre" valor={empleado?.nombre} error={errores?.nombre} />
      <Campo nombre="apellidos" etiqueta="Apellidos" valor={empleado?.apellidos} error={errores?.apellidos} />
      <Campo nombre="email" etiqueta="Email" valor={empleado?.email} error={errores?.email} />
      <Campo nombre="oficio" etiqueta="Oficio" valor={empleado?.oficio} error={errores?.oficio} />
      <Campo nombre="salario" etiqueta="Salario" tipo="number" valor={empleado?.salario} error={errores?.salario} />
      <div className="campo">
        <label htmlFor="deptId">Departamento</label>
        <select id="deptId" name="deptId" defaultValue={empleado?.deptId}>
          {departamentos.map((d) => (
            <option key={d.deptId} value={d.deptId}>
              {d.nombre}
            </option>
          ))}
        </select>
      </div>
      <button disabled={guardando}>{guardando ? 'Guardando…' : 'Guardar'}</button>
    </Form>
  );
}

interface PropsCampo {
  nombre: string;
  etiqueta: string;
  tipo?: string;
  valor?: string | number;
  error?: string;
}

function Campo({ nombre, etiqueta, tipo = 'text', valor, error }: PropsCampo) {
  return (
    <div className="campo">
      <label htmlFor={nombre}>{etiqueta}</label>
      <input
        id={nombre}
        name={nombre}
        type={tipo}
        defaultValue={valor}
        aria-invalid={Boolean(error)}
        aria-describedby={error ? `${nombre}-error` : undefined}
      />
      {error && (
        <span id={`${nombre}-error`} className="error">
          {error}
        </span>
      )}
    </div>
  );
}

Y regístralo en dos rutas, que comparten código y se distinguen por si hay params.id:

{
  path: 'empleados/nuevo',
  loader: EmpleadoForm.loader,
  action: EmpleadoForm.action,
  Component: EmpleadoForm.Component,
},
{
  path: 'empleados/:id/editar',
  loader: EmpleadoForm.loader,
  action: EmpleadoForm.action,
  Component: EmpleadoForm.Component,
},

Paso a paso, esto es lo que ocurre al pulsar Guardar:

  1. <Form method="post"> impide el envío del navegador, empaqueta los campos y hace una navegación de tipo POST a la URL de la ruta actual.
  2. navigation.state pasa a 'submitting'. El botón muestra “Guardando…” y se desactiva.
  3. Se ejecuta la action de esa ruta. request.formData() le entrega un FormData con los campos, que se identifican por su name.
  4. Si todo va bien, devuelve redirect. El router navega, y los loader activos se ejecutan de nuevo.
  5. Si la API responde 422, la action devuelve los errores y no cambia de URL. useActionData los recibe y se pintan bajo cada campo.

Formulario de alta con los campos en blanco y los mensajes de error de la API en rojo bajo Nombre, Apellidos, Email, Oficio y Salario

Figura 8. Los mensajes vienen de la respuesta 422 de la API. La action los devuelve y el formulario los pinta sin perder lo escrito.

Tres ideas que se deben entender bien:

Devolver frente a lanzar. Un error de validación es un resultado esperado: el usuario se ha equivocado y puede corregirlo. Por eso se devuelve. Un fallo inesperado, como que se caiga la API, no lo puede arreglar el formulario. Por eso se relanza, y lo recoge la frontera de error de la sección 13.

Campos sin estado. No hay ningún useState. Los campos son no controlados, con defaultValue (módulo 08), y su contenido sigue ahí si la action devuelve errores, porque el formulario no se ha desmontado.

Revalidación automática. Tras la action, los loader activos se repiten. Lo comprobaste en el experimento de la sección 8:

middleware padre: entra
middleware hijo: entra
action hijo 2
middleware hijo: sale
middleware padre: sale
middleware padre: entra
loader padre: empieza
loader padre: termina
middleware padre: sale
render Padre
render Padre
render Otra
render Otra

Observa que el middleware se ejecuta dos veces: una para la action y otra para los loader de la revalidación. Y que el loader padre se repite aunque esa ruta no había cambiado. No tienes que acordarte de refrescar nada tras guardar: la pantalla siguiente siempre refleja la escritura. Este es el mayor beneficio del modelo.

💡 Consejo: La función redirect también admite cabeceras y un código de estado, y hay una variante, replace('/ruta'), que sustituye la entrada del historial en vez de añadir otra.

Form de React Router frente a las Actions de React

En el módulo 16 viste <form action={...}> y useActionState. Se parecen y no compiten:

  Actions de React action de React Router
Dónde vive En un componente En una ruta
Disparador <form action={fn}> <Form method="post">
Estado de envío useFormStatus, isPending useNavigation, fetcher.state
Resultado useActionState useActionData
Tras escribir Actualizas el estado tú Revalida los loader solo
Redirigir useNavigate return redirect()

Dentro de una aplicación con modo Datos, usa action: está integrada con la navegación y la revalidación. Las Actions de React encajan en componentes que no dependen de una ruta. Las dos cosas pueden convivir en un mismo proyecto.


12. Cambios sin navegar con useFetcher

<Form> provoca una navegación: cambia la URL y hay un solo estado global. Pero hay operaciones que no deberían navegar: marcar una tarea, borrar una fila, cargar un desplegable. Además, en una tabla puedes tener varias en marcha a la vez.

useFetcher llama a una action o a un loader sin cambiar de pantalla, y cada instancia tiene su propio estado. Míralo en FilaEmpleado, en src/rutas/empleados.tsx:

function FilaEmpleado({ empleado, esAdmin }: PropsFila) {
  const fetcher = useFetcher<{ error?: string }>();
  const borrando = fetcher.state !== 'idle';
  // ...
  <fetcher.Form method="post" action={`/empleados/${empleado.empId}/borrar`}>
    <button disabled={borrando}>Borrar</button>
  </fetcher.Form>
}

El action del formulario apunta a una ruta sin componente, que solo sirve para recibir el envío. Crea src/rutas/empleado-borrar.tsx:

import { type ActionFunctionArgs } from 'react-router';
import { ErrorApi, pedir } from '../api';

// Ruta sin componente: solo recibe el envío del fetcher.
export async function action({ params }: ActionFunctionArgs) {
  try {
    await pedir(`/empleados/${params.id}`, { metodo: 'DELETE' });
    return { error: undefined };
  } catch (error) {
    if (error instanceof ErrorApi && [403, 409].includes(error.status)) {
      return { error: error.message };
    }
    throw error;
  }
}

Y se registra sin Component:

{ path: 'empleados/:id/borrar', action: EmpleadoBorrar.action },

Inicia sesión como ana.garcia@empresa.es (en la sección 14) e intenta borrarte a ti misma, a Ana García, la directora. La API responde 409 porque tiene subordinados, y el mensaje aparece solo en esa fila:

Tabla de empleados con un mensaje de error en rojo bajo una fila, y el resto de la tabla intacta

Figura 9. Cada fila tiene su fetcher y su resultado en fetcher.data. Un error en una fila no afecta a las demás.

Si el borrado funciona, la fila desaparece sin que hagas nada, porque los loader activos se vuelven a ejecutar, igual que tras un <Form>. Al crear un empleado de prueba y borrarlo, el contador pasó solo de Empleados (1) a Empleados (0).

Propiedad Para qué sirve
fetcher.state 'idle', 'submitting' o 'loading'
fetcher.data Lo que devolvió la action o el loader
fetcher.formData Lo que se está enviando; ideal para la interfaz optimista
fetcher.Form Formulario que no navega
fetcher.submit(datos, opciones) Envío por código
fetcher.load(url) Ejecuta el loader de una ruta sin navegar

¿Form o fetcher? Hazte esta pregunta: ¿debe cambiar la URL, y debe Atrás deshacerlo? Si la respuesta es sí, Form. Si no, useFetcher.

Interfaz optimista

fetcher.formData contiene lo enviado mientras dura la operación, y gracias a eso puedes pintar el resultado antes de que llegue:

const estado = fetcher.formData?.get('estado')?.toString() ?? pedido.estado;

Al terminar, formData vuelve a ser undefined, y se usa el dato ya revalidado. Si la action falla, la interfaz vuelve sola al valor anterior, sin que haya nada que deshacer. Lo practicarás en el ejercicio 6.


13. Errores por ruta

Un loader, una action o un componente pueden fallar. El router atrapa esos errores y pinta el ErrorBoundary de la ruta más cercana que lo tenga, sustituyendo el contenido de esa ruta. El mecanismo se apoya en las Error Boundaries del módulo 15, pero aquí no hay que escribir ninguna clase.

Cuatro rutas anidadas. El error de la ruta interior lo recoge su propia frontera. Si no la tiene, sube a la ruta padre más cercana

Figura 10. Cuanto más abajo está la frontera, menos parte de la pantalla se pierde.

Crea src/rutas/error.tsx:

import { isRouteErrorResponse, Link, useRouteError } from 'react-router';
import { ErrorApi } from '../api';

function describir(error: unknown): { titulo: string; detalle: string } {
  // Errores que crea el router (404 de ruta) o que lanzas con data().
  if (isRouteErrorResponse(error)) {
    return {
      titulo: `Error ${error.status}`,
      detalle: typeof error.data === 'string' ? error.data : error.statusText,
    };
  }
  // Errores de nuestra API.
  if (error instanceof ErrorApi) {
    return { titulo: `Error ${error.status}`, detalle: error.message };
  }
  // Cualquier otra cosa: un fallo de programación o de red.
  if (error instanceof Error) {
    return { titulo: 'Algo ha fallado', detalle: error.message };
  }
  return { titulo: 'Error desconocido', detalle: String(error) };
}

export function ErrorRuta() {
  const { titulo, detalle } = describir(useRouteError());

  return (
    <section className="tarjeta" role="alert">
      <h2>{titulo}</h2>
      <p>{detalle}</p>
      <Link to="/">Volver al inicio</Link>
    </section>
  );
}

useRouteError devuelve lo que se haya lanzado, que puede ser cualquier cosa. Hay que distinguir tres casos, y por eso el tipo es unknown:

Lo que se lanza Lo que recibe useRouteError Cómo se detecta
Ruta inexistente sin path: '*' Una respuesta de error con status: 404 isRouteErrorResponse(error)
throw data('Mensaje', { status: 400 }) Una respuesta con status y data isRouteErrorResponse(error)
throw new ErrorApi(...) Tu objeto, tal cual error instanceof ErrorApi
Un fallo de programación El Error error instanceof Error

La función data sirve para dar un código de estado a un dato. En empleado.tsx la usamos para rechazar un identificador que no es un número:

if (!/^\d+$/.test(params.id ?? '')) {
  throw data('El identificador debe ser un número', { status: 400 });
}

Tarjeta de error dentro de la aplicación con el título Error 400 y el texto El identificador debe ser un número

Figura 11. Resultado de abrir /empleados/abc. La cabecera sigue en pie.

Y esto es lo que ves cuando el error lo provoca la API, en /empleados/9, con un identificador válido que no existe:

Tarjeta de error con el título Error 404 y el texto Empleado 9 no encontrado

Figura 12. El ErrorApi llega intacto a useRouteError, con su status y su mensaje.

Dónde colocar las fronteras

Registra las fronteras en varios niveles. Las rutas de la aplicación quedan así:

{
  id: 'raiz',
  path: '/',
  ErrorBoundary: ErrorRuta, // Última red: si falla la propia raíz
  children: [
    {
      // Sin path: los errores de las hijas se pintan dentro del diseño
      ErrorBoundary: ErrorRuta,
      children: [
        /* todas las pantallas */
      ],
    },
  ],
},

Sin esa ruta intermedia sin path, un error en la página de empleados sustituiría a la raíz, con su cabecera incluida, y el usuario se quedaría sin menú. Es un truco que debes conocer.

Y para el detalle de un departamento, una frontera propia. Crea src/rutas/departamento.tsx:

import { Link, useLoaderData, type LoaderFunctionArgs } from 'react-router';
import { pedir } from '../api';
import type { Departamento, Empleado, Pagina } from '../tipos';
import { ErrorRuta } from './error';

export async function loader({ params, request }: LoaderFunctionArgs) {
  const { signal } = request;
  const [departamento, plantilla] = await Promise.all([
    pedir<Departamento>(`/departamentos/${params.id}`, { signal }),
    pedir<Pagina<Empleado>>(`/departamentos/${params.id}/empleados`, { signal }),
  ]);
  return { departamento, empleados: plantilla.contenido };
}

export function Component() {
  const { departamento, empleados } = useLoaderData<typeof loader>();

  return (
    <section className="tarjeta">
      <h2>{departamento.nombre}</h2>
      <p>
        {departamento.localidad}, {empleados.length} empleados
      </p>
      <ul>
        {empleados.map((e) => (
          <li key={e.empId}>
            <Link to={`/empleados/${e.empId}`}>
              {e.nombre} {e.apellidos}
            </Link>
          </li>
        ))}
      </ul>
    </section>
  );
}

// La frontera de error de este nivel deja en pie la lista lateral.
export { ErrorRuta as ErrorBoundary };

Abre /departamentos/99. La API responde 404, pero la lista lateral sigue en su sitio:

Página de departamentos con la lista lateral completa y una tarjeta que indica Error 404 y Departamento 99 no encontrado

Figura 13. El error solo ha ocupado el Outlet de Departamentos. El usuario puede elegir otro departamento sin recargar.

Registrar errores

Si quieres enviar los errores a un servicio de monitorización, no lo hagas en el ErrorBoundary, que se puede renderizar varias veces. <RouterProvider> tiene la prop onError, que se llama una vez por error:

<RouterProvider
  router={router}
  onError={(error, { location, pattern }) => {
    console.error('Fallo en', pattern, location.pathname, error);
  }}
/>

⚠️ Importante: Las fronteras de error del router no atrapan los errores de los manejadores de eventos, porque ocurren fuera del renderizado. Si un onClick falla, sigue siendo cosa tuya.


14. Rutas protegidas

Algunas pantallas exigen sesión. Crea la pantalla de acceso en src/rutas/login.tsx:

import {
  Form,
  redirect,
  useActionData,
  useNavigation,
  useSearchParams,
  type ActionFunctionArgs,
  type LoaderFunctionArgs,
} from 'react-router';
import { ErrorApi, pedir } from '../api';
import { contextoSesion } from '../middleware';
import { borrarSesion, guardarSesion } from '../sesion';
import type { Sesion } from '../tipos';

// Quien ya tiene sesión no pinta nada en el formulario.
export function loader({ context }: LoaderFunctionArgs) {
  return context.get(contextoSesion) ? redirect('/') : null;
}

// Evita redirecciones a otros sitios: solo rutas internas.
function destinoSeguro(desde: string | null) {
  return desde?.startsWith('/') && !desde.startsWith('//') ? desde : '/';
}

export async function action({ request, url }: ActionFunctionArgs) {
  const formulario = await request.formData();
  try {
    const sesion = await pedir<Sesion>('/auth/login', {
      metodo: 'POST',
      cuerpo: Object.fromEntries(formulario),
    });
    guardarSesion(sesion);
    return redirect(destinoSeguro(url.searchParams.get('desde')));
  } catch (error) {
    if (error instanceof ErrorApi && error.status === 401) {
      return { error: 'Email o contraseña incorrectos' };
    }
    throw error;
  }
}

export function Component() {
  const resultado = useActionData<typeof action>();
  const navegacion = useNavigation();
  const [parametros] = useSearchParams();

  return (
    <Form method="post" className="tarjeta" style={{ maxWidth: 360 }}>
      <h1>Iniciar sesión</h1>
      {parametros.has('caducada') && <p className="aviso">Tu sesión ha caducado.</p>}
      {parametros.has('desde') && <p className="aviso">Entra para continuar.</p>}
      <div className="campo">
        <label htmlFor="email">Email</label>
        <input id="email" name="email" type="email" defaultValue="ana.garcia@empresa.es" required />
      </div>
      <div className="campo">
        <label htmlFor="password">Contraseña</label>
        <input id="password" name="password" type="password" required />
      </div>
      {resultado?.error && <p className="error">{resultado.error}</p>}
      <button disabled={navegacion.state !== 'idle'}>Entrar</button>
    </Form>
  );
}

// /logout no tiene pantalla: solo una action.
export function salir() {
  borrarSesion();
  return redirect('/');
}

Todo lo que contiene ya lo conoces: un loader, una action y un Form. La API devuelve 401 si las credenciales son malas, y la action lo devuelve como dato:

Formulario de inicio de sesión con el aviso Entra para continuar y el mensaje Email o contraseña incorrectos

Figura 14. El aviso superior aparece porque la URL lleva ?desde=. El error lo devuelve la action.

Observa destinoSeguro. Comprueba que desde es una ruta interna, porque ese parámetro viene de la URL, y cualquiera podría enviarte un enlace a /login?desde=https://sitio-malo.com. Es lo que se conoce como redirección abierta.

Proteger con middleware 🔄 19.2

🔄 Cambia en React 19.2. Con React 19.2.7 o superior y React Router 8, en React Router 8 middleware es una función estable y context siempre tiene su tipo. Si usas una versión anterior: en React Router 7.18 pasa future: { v8_middleware: true } a createBrowserRouter, como pide su documentación, y amplía interface Future para que context no sea any. Los detalles están en la sección 20.

Un middleware es una función que envuelve la ejecución de los loader y las action de una ruta y de sus hijas. Se declara como array en la ruta, y corre de padre a hijo a la ida, y de hijo a padre a la vuelta:

middleware padre: entra
  middleware hijo: entra
    loaders
  middleware hijo: sale
middleware padre: sale

Recibe los mismos argumentos que un loader, y una función next que ejecuta el resto de la cadena. Crea src/middleware.ts:

import { createContext, redirect, type MiddlewareFunction } from 'react-router';
import { leerSesion } from './sesion';
import type { Sesion } from './tipos';

export const contextoSesion = createContext<Sesion | null>(null);

// Se ejecuta en cada navegación: mide cuánto tardan los loaders y actions.
export const registrar: MiddlewareFunction = async ({ request, pattern }, next) => {
  const inicio = performance.now();
  await next();
  const ms = Math.round(performance.now() - inicio);
  console.log(`[ruta] ${request.method} ${pattern} en ${ms} ms`);
};

// Deja la sesión en el contexto para las rutas hijas.
export const cargarSesion: MiddlewareFunction = ({ context }) => {
  context.set(contextoSesion, leerSesion());
};

// Sin sesión, a /login recordando adónde iba el usuario.
export const exigirSesion: MiddlewareFunction = ({ context, url }) => {
  if (!context.get(contextoSesion)) {
    const desde = encodeURIComponent(url.pathname + url.search);
    throw redirect(`/login?desde=${desde}`);
  }
};

Hay tres middleware, cada uno con un trabajo:

  • registrar usa await next() para ejecutar código antes y después. Sin llamar a next, el router lo hace por ti al terminar la función. Solo lo necesitas si quieres hacer algo a la vuelta.
  • cargarSesion guarda la sesión en el contexto del router. createContext crea una clave con tipo, y context.set y context.get escriben y leen. Es lo que nos permite escribir en raiz.tsx context.get(contextoSesion).
  • exigirSesion lanza un redirect si no hay sesión. Al lanzarlo, no se ejecuta nada de lo que hay por debajo: ni los loader ni la action.

⚠️ Importante: Este createContext es el de react-router, y no tiene nada que ver con el createContext de React del módulo 11. Son dos cosas con el mismo nombre: el primero pasa datos entre middleware, loader y action, fuera de React; el segundo, entre componentes. Importa el correcto.

La protección se aplica a una ruta sin path, de modo que afecta a todas sus hijas sin añadir nada a la URL:

{
  // Zona privada: sin path, solo aporta el middleware
  middleware: [exigirSesion],
  children: [
    { path: 'empleados/nuevo', /* ... */ },
    { path: 'empleados/:id/editar', /* ... */ },
    { path: 'empleados/:id/borrar', action: EmpleadoBorrar.action },
  ],
},

Haz la prueba: sin sesión, entra en /empleados/nuevo. Te lleva a /login?desde=%2Fempleados%2Fnuevo. Al iniciar sesión, la action te devuelve a esa pantalla, y la cabecera muestra Ana García (ADMIN) sin recargar, porque tras la action se revalida el loader raíz.

Salir es otra action, sin pantalla:

{ path: 'logout', action: Login.salir },

En cliente, el middleware corre en cada navegación, tenga la ruta loader o no. Por eso sirve también para una pantalla sin datos. En modo Framework en el servidor no es así, y la documentación oficial lo explica aparte.

Tres formas de proteger

Forma Ventaja Inconveniente
Comprobar en un componente envoltorio con <Navigate> Vale en modo Declarativo Los loader de las hijas ya se han ejecutado, porque corren antes de pintar
Comprobar en el loader Sencillo Los loader de una misma navegación van en paralelo: comprobar en el padre no detiene el del hijo
middleware Corre antes que los loader y las action, y se hereda en toda la rama Solo en modos Datos y Framework

⚠️ Importante: Nada de lo que hagas en el cliente protege tus datos. Todo este código se ejecuta en el navegador del usuario, y lo puede saltar con las herramientas de desarrollo: en nuestra aplicación, basta con escribir en localStorage. Una ruta protegida en el cliente es una comodidad para el usuario honesto: le evita ver pantallas que no le funcionarán. La seguridad real está en el servidor: cada petición debe comprobar el token y el rol, como hace la API de EMPRESA con su 401 y su 403. Si un dato no debe verlo un usuario, no se lo envíes.

💡 Consejo: localStorage es lo más sencillo para aprender, pero un script inyectado por XSS puede leerlo. Lo verás en el módulo 25. En producción, se suele preferir una cookie HttpOnly, que el JavaScript no puede leer.


15. Carga perezosa de rutas

Con todo en un solo fichero, el usuario descarga el código de las pantallas que quizá no visite nunca. En el módulo 15 dividiste el código con lazy de React. En una ruta, es mejor la propiedad lazy del propio router, porque también carga el loader, y no solo el componente.

lazy es una función que devuelve una promesa con las propiedades de la ruta. Si el fichero exporta loader, action, Component y ErrorBoundary con esos nombres, basta un import(). Para eso nombramos así las exportaciones. Crea src/rutas/departamentos.tsx:

import { NavLink, Outlet, useLoaderData, type LoaderFunctionArgs } from 'react-router';
import { pedir } from '../api';
import type { Departamento, Pagina } from '../tipos';

export async function loader({ request }: LoaderFunctionArgs) {
  const { contenido } = await pedir<Pagina<Departamento>>('/departamentos', {
    signal: request.signal,
  });
  return { departamentos: contenido };
}

export function Component() {
  const { departamentos } = useLoaderData<typeof loader>();

  return (
    <>
      <h1>Departamentos</h1>
      <div className="dos-columnas">
        <ul className="lateral">
          {departamentos.map((d) => (
            <li key={d.deptId}>
              <NavLink to={String(d.deptId)}>{d.nombre}</NavLink>
            </li>
          ))}
        </ul>
        <Outlet />
      </div>
    </>
  );
}

export function ErrorBoundary() {
  return <p className="error">No se ha podido cargar la lista de departamentos.</p>;
}

Y este es el src/router.tsx completo de la aplicación:

import type { RouteObject } from 'react-router';
import { cargarSesion, exigirSesion, registrar } from './middleware';
import { ErrorRuta } from './rutas/error';
import * as EmpleadoBorrar from './rutas/empleado-borrar';
import * as Empleado from './rutas/empleado';
import * as EmpleadoForm from './rutas/empleado-form';
import * as Empleados from './rutas/empleados';
import * as Inicio from './rutas/inicio';
import * as Login from './rutas/login';
import * as Raiz from './rutas/raiz';

export const rutas: RouteObject[] = [
  {
    id: 'raiz',
    path: '/',
    middleware: [registrar, cargarSesion],
    loader: Raiz.loader,
    Component: Raiz.Component,
    HydrateFallback: Raiz.HydrateFallback,
    ErrorBoundary: ErrorRuta,
    children: [
      {
        // Sin path: los errores de las hijas se pintan dentro del Layout.
        ErrorBoundary: ErrorRuta,
        children: [
          { index: true, loader: Inicio.loader, Component: Inicio.Component },
          { path: 'empleados', loader: Empleados.loader, Component: Empleados.Component },
          { path: 'empleados/:id', loader: Empleado.loader, Component: Empleado.Component },
          {
            // Zona privada: sin path, solo aporta el middleware.
            middleware: [exigirSesion],
            children: [
              {
                path: 'empleados/nuevo',
                loader: EmpleadoForm.loader,
                action: EmpleadoForm.action,
                Component: EmpleadoForm.Component,
              },
              {
                path: 'empleados/:id/editar',
                loader: EmpleadoForm.loader,
                action: EmpleadoForm.action,
                Component: EmpleadoForm.Component,
              },
              { path: 'empleados/:id/borrar', action: EmpleadoBorrar.action },
            ],
          },
          {
            path: 'departamentos',
            lazy: () => import('./rutas/departamentos'),
            children: [
              {
                index: true,
                element: <p>Elige un departamento de la lista.</p>,
              },
              { path: ':id', lazy: () => import('./rutas/departamento') },
            ],
          },
          {
            path: 'login',
            loader: Login.loader,
            action: Login.action,
            Component: Login.Component,
          },
          { path: 'logout', action: Login.salir },
          { path: '*', Component: NoEncontrada },
        ],
      },
    ],
  },
];

function NoEncontrada() {
  return (
    <section className="tarjeta">
      <h2>Página no encontrada</h2>
      <p>Esa dirección no corresponde a ninguna pantalla.</p>
    </section>
  );
}

Fíjate en cómo se escribe:

{ path: 'departamentos', lazy: () => import('./rutas/departamentos') },

Esta es la salida de npm run build, en la que cada ruta perezosa tiene su propio fichero:

dist/index.html                          0.45 kB │ gzip:   0.29 kB
dist/assets/index-CJDhiYcS.css           2.27 kB │ gzip:   0.86 kB
dist/assets/departamentos-Cpb268J0.js    0.69 kB │ gzip:   0.38 kB
dist/assets/departamento-D5gGUhUw.js     0.71 kB │ gzip:   0.40 kB
dist/assets/index-C4BhJ9TI.js          335.37 kB │ gzip: 105.55 kB
✓ built in 6.79s

Cuando el usuario pulsa en Departamentos, el router descarga ese trozo y luego ejecuta su loader, todo dentro de la navegación, y con el mismo estado 'loading'. No necesitas ningún <Suspense>.

Pantalla de departamentos con INFORMÁTICA seleccionado y sus empleados en una tarjeta

Figura 15. Las dos rutas de esta pantalla se han descargado al pulsar el enlace, y el loader de cada una se ha ejecutado después.

Hay una forma más precisa con un objeto, que carga cada propiedad por separado. Es útil si un loader pequeño no debe esperar a un componente grande:

{
  path: 'informes',
  lazy: {
    loader: () => import('./rutas/informes-datos').then((m) => m.loader),
    Component: () => import('./rutas/informes').then((m) => m.Component),
  },
},

⚠️ Importante: No todo se puede cargar de forma perezosa. path, index, children, id y caseSensitive deben ir en la ruta, porque el router los necesita para emparejar antes de descargar nada. middleware tampoco se admite en la forma con función, aunque sí en la forma con objeto. Si exportas Component y también una default que no usas, no pasa nada, pero la default no cuenta: lazy no reconoce esa exportación.

💡 Consejo: Si un import() perezoso falla, por ejemplo porque el usuario tiene la aplicación abierta y has desplegado una versión nueva, el error sube a la frontera más cercana. Desde la versión 8.5.1 el router reintenta la importación la próxima vez, en lugar de recordar el fallo para siempre.


16. Datos lentos con <Await>

Un loader bloquea la navegación hasta que termina. Si un dato es lento y secundario, no conviene que retenga toda la pantalla. La solución es no esperar por él: devuelve la promesa sin resolver, dentro de un objeto. El router no la espera, y tú la consumes con <Await> dentro de un <Suspense>. Crea src/rutas/inicio.tsx:

import { Suspense } from 'react';
import { Await, Link, useLoaderData, type LoaderFunctionArgs } from 'react-router';
import { pedir } from '../api';
import type { Estadisticas } from '../tipos';

export function loader({ request }: LoaderFunctionArgs) {
  // Sin await: la navegación no espera a esta promesa.
  const estadisticas = pedir<Estadisticas>('/estadisticas', { signal: request.signal });
  return { estadisticas };
}

export function Component() {
  const { estadisticas } = useLoaderData<typeof loader>();

  return (
    <>
      <h1>Panel de EMPRESA</h1>
      <section className="tarjeta">
        <h2>Cifras generales</h2>
        <Suspense fallback={<p>Calculando cifras…</p>}>
          <Await
            resolve={estadisticas}
            errorElement={<p className="error">No se han podido cargar las cifras.</p>}
          >
            {(datos: Estadisticas) => (
              <div className="cifras">
                <div>
                  <b>{datos.empleados}</b> empleados
                </div>
                <div>
                  <b>{datos.departamentos}</b> departamentos
                </div>
                <div>
                  <b>{datos.clientes}</b> clientes
                </div>
                <div>
                  <b>{datos.pedidos}</b> pedidos
                </div>
              </div>
            )}
          </Await>
        </Suspense>
      </section>
      <Link className="boton" to="/empleados">
        Ver la plantilla
      </Link>
    </>
  );
}

Panel de EMPRESA con una tarjeta de cifras generales: 24 empleados, 7 departamentos, 14 clientes y 24 pedidos

Figura 16. La pantalla aparece al instante con “Calculando cifras…”, y las cifras llegan después.

  • La promesa debe ir dentro de un objeto. Si un loader devuelve directamente una promesa, la función asíncrona la espera, como cualquier otra.
  • errorElement recoge el rechazo de la promesa. Sin él, el error sube a la frontera de error de la ruta.
  • Los datos que esperas con await en el loader están listos al pintar. Los que devuelves como promesa llegan después. Puedes mezclarlos en un mismo loader.

Esto recuerda a use(promesa) del módulo 15, y de hecho puedes pasar esa misma promesa a use dentro de <Suspense>. Pero aquí está resuelto lo más difícil de ese módulo, que era conseguir una promesa estable: la crea el loader una sola vez por navegación.

¿Cuándo aplazar y cuándo esperar? Espera los datos sin los que la pantalla no tiene sentido, y aplaza los accesorios, como las estadísticas de un panel o las recomendaciones de una ficha.


17. Otras herramientas útiles

Impedir que se pierdan cambios: useBlocker

useBlocker retiene una navegación hasta que el usuario decida. Lo tienes resuelto en el ejercicio 5.

Migas de pan: handle y useMatches

handle es un objeto libre que asocias a una ruta. useMatches devuelve la rama activa, con el handle y los loaderData de cada nivel. Juntos permiten construir migas de pan o títulos de página sin que cada pantalla los gestione. Lo tienes en el ejercicio 4.

Título de la página

React 19 permite renderizar <title> en cualquier componente (módulo 07):

export function Component() {
  const { empleado } = useLoaderData<typeof loader>();
  return (
    <>
      <title>{`${empleado.nombre} | EMPRESA`}</title>
      {/* ... */}
    </>
  );
}

Generar enlaces

generatePath evita concatenar cadenas y codifica los parámetros. Da igual si los necesitas para un Link o un redirect. Sus valores deben ser cadenas, y un número es un error de TypeScript:

const destino = generatePath('/empleados/:id/editar', { id: String(empleado.empId) });

Transiciones de vista entre pantallas 🆕 19.3

🆕 Desde React 19.3. <ViewTransition> no existe en React 19.2 ni anteriores. Si usas una versión anterior: usa la prop viewTransition de <Link>, <NavLink> y <Form>. Está disponible en React Router 7 y 8, aunque en 8.5 quedó obsoleta. Sigue funcionando y no exige React 19.3, pero se apoya en la API de React Router, que algún día desaparecerá.

Desde la versión 8.5, la recomendación oficial es usar el <ViewTransition> de React en vez de la API del router. Se coloca alrededor del Outlet, en la ruta de diseño:

import { ViewTransition } from 'react';
import { Link, Outlet } from 'react-router';

function Marco() {
  return (
    <>
      <nav className="fila">
        <Link to="/">Uno</Link>
        <Link to="/dos">Dos</Link>
      </nav>
      <ViewTransition>
        <main>
          <Outlet />
        </main>
      </ViewTransition>
    </>
  );
}

Funciona sin más porque, como has visto, el router envuelve sus cambios en transiciones de React. Hemos comprobado que una navegación con este componente llama una vez a document.startViewTransition. Los detalles de las animaciones están en el módulo 17.


18. Los otros dos modos

Modo Declarativo

Si no quieres loader ni action, solo necesitas <BrowserRouter>, <Routes> y <Route>. Las rutas se escriben en JSX, y main.tsx queda así:

import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router';
import { App } from './App';

createRoot(document.getElementById('root')!).render(
  <BrowserRouter>
    <App />
  </BrowserRouter>,
);

Este es un ejemplo completo. Los datos vuelven a cargarse en un efecto, con los problemas de la sección 8:

import { useEffect, useState, type ReactNode } from 'react';
import { Navigate, NavLink, Route, Routes, useParams } from 'react-router';
import type { Departamento, Pagina } from './tipos';

const API = import.meta.env.VITE_API ?? 'http://localhost:3000/api';

function Layout({ children }: { children: ReactNode }) {
  return (
    <>
      <header className="cabecera">
        <strong>EMPRESA</strong>
        <nav aria-label="Principal">
          <NavLink to="/departamentos">Departamentos</NavLink>
        </nav>
      </header>
      <main>{children}</main>
    </>
  );
}

function Departamentos() {
  const [departamentos, setDepartamentos] = useState<Departamento[] | null>(null);

  // Sin loaders, los datos vuelven a depender de un efecto.
  useEffect(() => {
    const control = new AbortController();
    fetch(`${API}/departamentos`, { signal: control.signal })
      .then((r) => r.json() as Promise<Pagina<Departamento>>)
      .then((pagina) => setDepartamentos(pagina.contenido))
      .catch(() => {});
    return () => control.abort();
  }, []);

  if (!departamentos) return <p>Cargando…</p>;
  return (
    <ul>
      {departamentos.map((d) => (
        <li key={d.deptId}>
          <NavLink to={String(d.deptId)}>{d.nombre}</NavLink>
        </li>
      ))}
    </ul>
  );
}

function Detalle() {
  const { id } = useParams();
  return <h1>Departamento {id}</h1>;
}

export function App() {
  return (
    <Layout>
      <Routes>
        <Route index element={<Navigate to="/departamentos" replace />} />
        <Route path="departamentos" element={<Departamentos />} />
        <Route path="departamentos/:id" element={<Detalle />} />
        <Route path="*" element={<h1>Esa página no existe</h1>} />
      </Routes>
    </Layout>
  );
}

Las rutas anidadas, Outlet, Link, useParams, useNavigate, useLocation y useSearchParams funcionan igual. Lo que no funciona es lo que depende de un data router. Si llamas a useLoaderData bajo un <BrowserRouter>, la aplicación se rompe con este error real:

useLoaderData must be used within a data router.

Este modo combina muy bien con TanStack Query (módulo 19), que resuelve la parte de los datos por su cuenta.

💡 Consejo: Cuando un proyecto en modo Declarativo crece, migrar al modo Datos es fácil: createRoutesFromElements convierte tus <Route> en objetos de ruta, y puedes ir añadiendo loader poco a poco.

Modo Framework

Aquí React Router deja de ser una biblioteca y pasa a ser la base de tu proyecto. Se crea con su propia plantilla:

npx create-react-router@latest mi-app

Lo principal cambia así:

  • Vite lleva el plugin reactRouter() en lugar de @vitejs/plugin-react.
  • Las rutas se declaran en app/routes.ts, apuntando a ficheros:
import { index, route, type RouteConfig } from '@react-router/dev/routes';

export default [
  index('routes/inicio.tsx'),
  route('empleados/:id', 'routes/empleado.tsx'),
] satisfies RouteConfig;
  • Cada módulo de ruta exporta su componente por defecto, y recibe los datos como props, con tipos que se generan para cada ruta. Fíjate en que params.id no es string | undefined, sino string:
import type { Route } from './+types/empleado';

export async function loader({ params }: Route.LoaderArgs) {
  return { id: Number(params.id) };
}

export default function Empleado({ loaderData }: Route.ComponentProps) {
  return <h1>Empleado {loaderData.id}</h1>;
}
  • El loader se ejecuta en el servidor, de modo que puede acceder a una base de datos directamente. clientLoader es el que se ejecuta en el navegador.
  • Se añaden @react-router/dev y @react-router/node como dependencias, y el proyecto se construye con react-router build, que genera build/client y build/server.

Para comprobarlo se creó un proyecto mínimo con @react-router/dev 8.5.1, Vite 8 y TypeScript 7, y las órdenes react-router build, react-router typegen y tsc terminaron sin errores. Es un mundo aparte, cercano a Next.js. Los conceptos de este módulo te sirven tal cual. El renderizado en servidor se estudia en el módulo 23.


19. Desplegar una aplicación con rutas

Hay algo que funciona en desarrollo y falla en producción, y sorprende a todo el mundo: recargar en una ruta profunda.

Abre /empleados/100 y recarga. El navegador le pide al servidor el fichero /empleados/100. En desarrollo, Vite y vite preview responden con index.html, y el router hace el resto. Pero un servidor de ficheros estáticos busca esa ruta en disco, no la encuentra y responde 404.

La solución es configurar el servidor para que devuelva index.html en cualquier ruta que no sea un fichero real:

# Nginx
location / {
  try_files $uri /index.html;
}

Si tu aplicación no cuelga de la raíz del dominio, como pasa en GitHub Pages con usuario.github.io/mi-repo/, indícalo con basename:

const router = createBrowserRouter(rutas, { basename: '/mi-repo' });

Con eso, <Link to="/empleados"> genera /mi-repo/empleados, y redirect('/login') también lo respeta. No hay que cambiar nada más en el código de las rutas.

Si no puedes tocar la configuración del servidor, createHashRouter guarda la ruta tras la almohadilla (/#/empleados/100). El servidor nunca ve esa parte, y siempre sirve index.html. Las direcciones son más feas y no se indexan bien, de modo que solo es un último recurso.

Lo verás todo en la práctica en el módulo 25.


20. React Router 7: la alternativa para React anterior a 19.2.7 🔄 19.2

🔄 Cambia en React 19.2. React Router 8 exige React 19.2.7 y no admite React 19.2.6 ni anteriores. Si usas una versión anterior: instala react-router@7.18.5. Este módulo funciona casi sin cambios: la aplicación EMPRESA se ha recorrido con esa versión sobre React 19.1.10 y 18.3.1, con los mismos resultados.

Esta tabla te dice qué instalar. La última línea de la versión 7, publicada con la etiqueta version-7 en npm, es la 7.18.5:

Tu React Tu Node Instala
19.2.7 o superior, 19.3 22.22 o superior react-router (8.5.1)
19.2.7 o superior, 19.3 20 o 22 anterior a 22.22 react-router@7
19.2.0 a 19.2.6 Cualquiera Sube a React 19.2.9 o instala react-router@7
19.1, 19.0 o 18.x Cualquiera react-router@7

Instalación

npm install react-router@7

Si ves el aviso ERESOLVE al instalar la versión 8 en un proyecto antiguo, ya sabes el motivo. No lo evites con --force o --legacy-peer-deps: forzamos la versión 8.5.1 sobre React 19.1.10 y la aplicación funcionó en una prueba rápida, pero es una combinación sin soporte, que nadie prueba por ti. La opción con garantías es usar la versión 7.

Qué cambia en tu código

Todas las importaciones de este módulo funcionan tal cual, tanto react-router como react-router/dom. Tampoco hace falta react-router-dom, aunque en la versión 7 existe y es un simple reexportador. Su index.d.ts tiene dos líneas:

export { HydratedRouter, RouterProvider, RouterProviderProps } from 'react-router/dom';
export * from 'react-router';

Solo hay que tocar el middleware.

1. Activa el indicador. La guía de migración pide future.v8_middleware, también en modo Datos:

const router = createBrowserRouter(rutas, {
  future: { v8_middleware: true },
});

2. Amplía los tipos. Sin esto el código compila, pero context queda como any, y pierdes la comprobación de tipos de context.get. Crea src/react-router.d.ts:

import 'react-router';

declare module 'react-router' {
  interface Future {
    v8_middleware: true;
  }
}

⚠️ Importante: Aquí la documentación y el comportamiento real no coinciden del todo. Probamos la aplicación con React Router 7.18.5 y React 19.1.10 pasando v8_middleware: false, y en modo Datos el middleware se ejecutó igual: registrar escribió en la consola, y /empleados/100/editar redirigió a /login?desde=.... No te fíes de esto: es un detalle de esa versión, no una garantía, y en modo Framework el indicador sí es determinante. Actívalo siempre, porque deja claro lo que usas y hace que el salto a la versión 8 sea un simple borrado.

Lo que no tendrás con React 18 o 19.1

Función usada en este módulo Disponible Alternativa
Todas las rutas, loader, action, Form, useFetcher, lazy, Await, fronteras de error Sí No hace falta
middleware, createContext Sí, en 7.18 Activa el indicador y amplía interface Future
<ViewTransition> No, exige React 19.3 Prop viewTransition de Link
<title> en un componente No en React 18 Asigna document.title en un efecto
use(promesa) No en React 18 <Await>, que funciona igual

Pasar más adelante de la 7 a la 8

Cuando tu React llegue a 19.2.7, el salto es pequeño:

  1. Sube Node a 22.22 o superior, y React a 19.2.7 o superior.
  2. Si tienes react-router-dom, cambia las importaciones: RouterProvider a react-router/dom, y todo lo demás a react-router. Luego ejecuta npm uninstall react-router-dom.
  3. Cambia matches[i].data por matches[i].loaderData.
  4. Quita future.v8_middleware y la ampliación de interface Future.
  5. Ejecuta npm install react-router@latest.

La guía oficial viene dentro del paquete, en node_modules/react-router/docs/upgrading/v7.md.


21. Errores frecuentes

Síntoma Causa Solución
ERESOLVE, peer react@">=19.2.7" React demasiado antiguo para la versión 8 Sube React, o instala react-router@7
Dos versiones de react-router en node_modules Has instalado react-router-dom, que trae la 7 Desinstálalo
La página entera se recarga al pulsar un enlace Has usado <a href> Usa <Link to>
Inicio siempre aparece activo Toda URL empieza por / Añade end al NavLink
La ruta hija no aparece Falta <Outlet /> en el padre Añádelo
Hueco en blanco en /departamentos No hay ruta índice Añade una hija con index: true
to=".." te lleva a la raíz .. sube una ruta, no un segmento relative="path", o una ruta absoluta
useLoaderData must be used within a data router Estás en modo Declarativo Pasa a createBrowserRouter
Aviso de HydrateFallback y pantalla en blanco No hay nada que pintar en la primera carga Añade HydrateFallback a la ruta raíz
El router se reinicia y pierde el estado createBrowserRouter está dentro de un componente Muévelo fuera
Los filtros se borran al paginar setParametros({...}) sustituye todo Usa la forma con función
El buscador no se vacía al pulsar Atrás Campo no controlado key={q}
La action no se ejecuta Un <form> en minúscula, o una ruta sin action Usa <Form>, y comprueba la ruta
Campos vacíos en la action Falta el atributo name Añade name a cada campo
405 Method Not Allowed al enviar La ruta destino no tiene action Declárala
Tras un error desaparece toda la aplicación La única frontera está en la raíz Añade una ruta sin path con ErrorBoundary
context es any en la versión 7 Falta ampliar interface Future Sección 20
404 al recargar en producción El servidor no devuelve index.html Configura el servidor, sección 19

22. Ejercicios

Parte del proyecto de la aplicación EMPRESA. Cada solución define sus propias rutas: para probarla, guárdala en src/ejercicios/ejN.tsx y cambia la importación de src/main.tsx:

import { rutas } from './ejercicios/ej1';

Ejercicio 1: Catálogo de productos

Crea dos rutas: /, con la lista de los 8 primeros productos, y /:id, con su ficha. Carga los datos con loader, formatea el precio en euros, y haz que un producto inexistente muestre un error dentro de esa ruta. Prueba con /999.

Ver solución
import { Link, useLoaderData, type LoaderFunctionArgs, type RouteObject } from 'react-router';
import { pedir } from '../api';
import { ErrorRuta } from '../rutas/error';
import type { Pagina } from '../tipos';

interface Producto {
  productoId: number;
  nombre: string;
  descripcion: string;
  categoria: string;
  precio: number;
  stock: number;
}

const euros = new Intl.NumberFormat('es-ES', { style: 'currency', currency: 'EUR' });

async function cargarProductos({ request }: LoaderFunctionArgs) {
  return pedir<Pagina<Producto>>('/productos?tamano=8', { signal: request.signal });
}

async function cargarProducto({ params, request }: LoaderFunctionArgs) {
  return pedir<Producto>(`/productos/${params.id}`, { signal: request.signal });
}

function Productos() {
  const { contenido } = useLoaderData<typeof cargarProductos>();
  return (
    <main>
      <h1>Productos</h1>
      <ul>
        {contenido.map((p) => (
          <li key={p.productoId}>
            <Link to={String(p.productoId)}>{p.nombre}</Link>, {euros.format(p.precio)}
          </li>
        ))}
      </ul>
    </main>
  );
}

function ProductoDetalle() {
  const producto = useLoaderData<typeof cargarProducto>();
  return (
    <main className="tarjeta">
      <h1>{producto.nombre}</h1>
      <p>{producto.descripcion}</p>
      <p>
        {producto.categoria}: {euros.format(producto.precio)}, quedan {producto.stock}
      </p>
      <Link to="..">Volver</Link>
    </main>
  );
}

export const rutas: RouteObject[] = [
  {
    path: '/',
    loader: cargarProductos,
    Component: Productos,
    HydrateFallback: () => <p>Cargando…</p>,
  },
  {
    path: ':id',
    loader: cargarProducto,
    Component: ProductoDetalle,
    ErrorBoundary: ErrorRuta,
    HydrateFallback: () => <p>Cargando…</p>,
  },
];

Las dos rutas son hermanas, no padre e hija, porque el detalle sustituye a la lista. Por eso to=".." funciona aquí: el padre de :id es la raíz implícita. El ErrorBoundary solo afecta a la ficha, y ErrorRuta muestra “Producto 999 no encontrado”, el mensaje de la API que viaja en el ErrorApi. Intl.NumberFormat se crea fuera del componente, porque crearlo es caro y no depende de nada del render.

Ejercicio 2: Filtro por estado en la URL

Lista los pedidos con un botón por estado (PENDIENTE, ENVIADO, ENTREGADO, CANCELADO) y uno de Todos. El filtro debe guardarse en la URL como ?estado=ENVIADO, sobrevivir a una recarga y deshacerse con Atrás. Marca el botón activo con aria-pressed.

Ver solución
import {
  useLoaderData,
  useNavigation,
  useSearchParams,
  type LoaderFunctionArgs,
  type RouteObject,
} from 'react-router';
import { pedir } from '../api';
import type { Pagina } from '../tipos';

const ESTADOS = ['PENDIENTE', 'ENVIADO', 'ENTREGADO', 'CANCELADO'];

interface Pedido {
  pedidoId: number;
  cliente: string;
  estado: string;
  total: number;
}

async function loader({ request, url }: LoaderFunctionArgs) {
  const consulta = new URLSearchParams(url.searchParams);
  consulta.set('tamano', '6');
  return pedir<Pagina<Pedido>>(`/pedidos?${consulta}`, { signal: request.signal });
}

function Pedidos() {
  const pedidos = useLoaderData<typeof loader>();
  const [parametros, setParametros] = useSearchParams();
  const navegacion = useNavigation();
  const estado = parametros.get('estado');

  function filtrar(nuevo: string | null) {
    setParametros(nuevo ? { estado: nuevo } : {});
  }

  return (
    <main aria-busy={navegacion.state !== 'idle'}>
      <h1>Pedidos ({pedidos.total})</h1>
      <div className="fila">
        <button aria-pressed={estado === null} onClick={() => filtrar(null)}>
          Todos
        </button>
        {ESTADOS.map((e) => (
          <button key={e} aria-pressed={estado === e} onClick={() => filtrar(e)}>
            {e}
          </button>
        ))}
      </div>
      <ul>
        {pedidos.contenido.map((p) => (
          <li key={p.pedidoId}>
            {p.pedidoId}, {p.cliente}: {p.estado}
          </li>
        ))}
      </ul>
    </main>
  );
}

export const rutas: RouteObject[] = [
  { path: '/', loader, Component: Pedidos, HydrateFallback: () => <p>Cargando…</p> },
];

El estado activo no se guarda en ningún useState: se lee de parametros. La URL es la única fuente de verdad. Aquí setParametros recibe un objeto, porque queremos sustituir el filtro, y {} lo elimina. Con el filtro ENVIADO la API devuelve 2 pedidos, y Atrás recupera los 24.

Lista de pedidos filtrada, con el botón ENVIADO resaltado y dos pedidos en la lista

Figura 17. El botón activo se deduce de la URL.

Ejercicio 3: Alta de departamento

Crea una ruta que liste los departamentos e incluya un formulario de alta, con una action que haga POST a /departamentos. Muestra los errores de validación de la API sin perder lo escrito. Al crear, la lista debe actualizarse y el formulario vaciarse. Necesitas haber iniciado sesión.

Ver solución
import {
  Form,
  redirect,
  useActionData,
  useLoaderData,
  useNavigation,
  type ActionFunctionArgs,
  type LoaderFunctionArgs,
  type RouteObject,
} from 'react-router';
import { ErrorApi, pedir } from '../api';
import { ErrorRuta } from '../rutas/error';
import { leerSesion } from '../sesion';
import type { Departamento, Pagina } from '../tipos';

async function loader({ request }: LoaderFunctionArgs) {
  return pedir<Pagina<Departamento>>('/departamentos', { signal: request.signal });
}

async function action({ request }: ActionFunctionArgs) {
  const datos = Object.fromEntries(await request.formData());
  const cuerpo = { ...datos, presupuesto: Number(datos.presupuesto) };

  try {
    await pedir('/departamentos', { metodo: 'POST', cuerpo });
    return redirect('/');
  } catch (error) {
    if (error instanceof ErrorApi && error.status === 422) {
      return { errores: error.detalles ?? {}, datos };
    }
    throw error;
  }
}

function Departamentos() {
  const { contenido } = useLoaderData<typeof loader>();
  const resultado = useActionData<typeof action>();
  const navegacion = useNavigation();

  return (
    <main>
      <h1>Departamentos ({contenido.length})</h1>
      <p>{contenido.map((d) => d.nombre).join(', ')}</p>
      {!leerSesion() && <p className="aviso">Inicia sesión antes de crear.</p>}
      <Form method="post" className="tarjeta" key={contenido.length}>
        <div className="campo">
          <label htmlFor="nombre">Nombre</label>
          <input id="nombre" name="nombre" defaultValue={String(resultado?.datos.nombre ?? '')} />
          {resultado?.errores.nombre && <span className="error">{resultado.errores.nombre}</span>}
        </div>
        <div className="campo">
          <label htmlFor="localidad">Localidad</label>
          <input id="localidad" name="localidad" defaultValue={String(resultado?.datos.localidad ?? '')} />
        </div>
        <div className="campo">
          <label htmlFor="presupuesto">Presupuesto</label>
          <input id="presupuesto" name="presupuesto" type="number" defaultValue="100000" />
        </div>
        <button disabled={navegacion.state !== 'idle'}>Crear</button>
      </Form>
    </main>
  );
}

export const rutas: RouteObject[] = [
  {
    path: '/',
    loader,
    action,
    Component: Departamentos,
    ErrorBoundary: ErrorRuta,
    HydrateFallback: () => <p>Cargando…</p>,
  },
];

El redirect('/') a la misma ruta activa los loader y lo que viene de useActionData desaparece. key={contenido.length} hace que React cree un formulario nuevo cuando la lista crece, y eso lo vacía. Al enviarlo sin nombre, la API responde 422 con El nombre es obligatorio. La action devuelve también los datos enviados por si el formulario se volviera a montar, y defaultValue los recupera. Ten en cuenta que los datos de la API viven en memoria: si la reinicias, el departamento desaparece.

Ejercicio 4: Migas de pan

Crea unas migas de pan automáticas que muestren Inicio / Departamentos / INFORMÁTICA en /departamentos/30. Cada ruta debe declarar su texto en handle, y el nombre del departamento debe salir de los datos del loader.

Ver solución
import {
  Link,
  Outlet,
  useLoaderData,
  useMatches,
  type LoaderFunctionArgs,
  type RouteObject,
} from 'react-router';
import { pedir } from '../api';
import type { Departamento } from '../tipos';

interface MigaHandle {
  miga: (datos: unknown) => string;
}

function tieneMiga(handle: unknown): handle is MigaHandle {
  return typeof handle === 'object' && handle !== null && 'miga' in handle;
}

function Migas() {
  const coincidencias = useMatches();

  return (
    <nav aria-label="Migas de pan">
      <ol className="migas">
        {coincidencias.map((c, i) => {
          const { handle } = c;
          if (!tieneMiga(handle)) return null;
          const texto = handle.miga(c.loaderData);
          const ultima = i === coincidencias.length - 1;
          return (
            <li key={c.id}>
              {ultima ? <span aria-current="page">{texto}</span> : <Link to={c.pathname}>{texto}</Link>}
            </li>
          );
        })}
      </ol>
    </nav>
  );
}

function Layout() {
  return (
    <main>
      <Migas />
      <Outlet />
    </main>
  );
}

async function cargarDepartamento({ params, request }: LoaderFunctionArgs) {
  return pedir<Departamento>(`/departamentos/${params.id}`, { signal: request.signal });
}

function Detalle() {
  const departamento = useLoaderData<typeof cargarDepartamento>();
  return (
    <p>
      {departamento.nombre} está en {departamento.localidad}.
    </p>
  );
}

export const rutas: RouteObject[] = [
  {
    path: '/',
    Component: Layout,
    handle: { miga: () => 'Inicio' },
    children: [
      { index: true, element: <Link to="departamentos/30">Ir a Informática</Link> },
      {
        path: 'departamentos',
        handle: { miga: () => 'Departamentos' },
        children: [
          { index: true, element: <Link to="30">Ir a Informática</Link> },
          {
            path: ':id',
            loader: cargarDepartamento,
            Component: Detalle,
            handle: { miga: (d: Departamento) => d.nombre },
          },
        ],
      },
    ],
  },
];

Migas de pan que indican Inicio, Departamentos e INFORMÁTICA sobre la frase INFORMÁTICA está en Valencia

Figura 18. El último nivel sale del loader de la ruta :id.

handle es de tipo unknown, ya que el router no sabe qué metes dentro. Por eso hay una guarda de tipos, tieneMiga, en lugar de un as. En React Router 7 también funciona, pero en documentación antigua verás c.data, que ya no existe en la versión 8: la propiedad es c.loaderData. Como departamentos no tiene componente, sus hijas se pintan en el Outlet de Layout.

Ejercicio 5: Cambios sin guardar

En un formulario, avisa al usuario si intenta salir con cambios sin guardar. Debe poder elegir entre salir o seguir editando, y el botón Guardar no debe disparar el aviso.

Ver solución
import { useState } from 'react';
import { Form, Link, redirect, useBlocker, type RouteObject } from 'react-router';

function Editor() {
  const [modificado, setModificado] = useState(false);
  const bloqueo = useBlocker(
    ({ currentLocation, nextLocation }) =>
      modificado && currentLocation.pathname !== nextLocation.pathname,
  );

  return (
    <main>
      <h1>Editar ficha</h1>
      <Form method="post" onChange={() => setModificado(true)}>
        <div className="campo">
          <label htmlFor="nombre">Nombre</label>
          <input id="nombre" name="nombre" />
        </div>
        <button onClick={() => setModificado(false)}>Guardar</button>
      </Form>
      <p>
        <Link to="/otra">Ir a otra página</Link>
      </p>

      {bloqueo.state === 'blocked' && (
        <div className="aviso" role="alertdialog" aria-label="Cambios sin guardar">
          <p>Tienes cambios sin guardar. ¿Quieres salir?</p>
          <button onClick={() => bloqueo.proceed()}>Salir</button>{' '}
          <button onClick={() => bloqueo.reset()}>Seguir editando</button>
        </div>
      )}
    </main>
  );
}

export const rutas: RouteObject[] = [
  { path: '/', Component: Editor, action: () => redirect('/otra') },
  { path: '/otra', element: <main><h1>Otra página</h1></main> },
];

Formulario con un aviso que pregunta si quieres salir, con los botones Salir y Seguir editando

Figura 19. useBlocker detiene la navegación hasta que el usuario decide.

La función que recibe useBlocker devuelve true para bloquear. bloqueo.state pasa de 'unblocked' a 'blocked', y entonces proceed() continúa y reset() cancela. El setModificado(false) del botón Guardar se ejecuta antes que el envío, y de esa forma la action no queda bloqueada.

useBlocker solo detiene las navegaciones dentro de la aplicación. Si el usuario cierra la pestaña o recarga, necesitas además useBeforeUnload, que usa el evento nativo beforeunload.

Ejercicio 6: Estado de un pedido con interfaz optimista

Muestra 5 pedidos, con un <select> para cambiar el estado de cada uno. El cambio debe guardarse con PATCH /pedidos/:id, sin navegar, y el <select> debe reflejar la elección al instante. Si la API lo rechaza, debe volver al valor real y mostrar el error. Necesitas haber iniciado sesión.

Ver solución
import {
  useFetcher,
  useLoaderData,
  type ActionFunctionArgs,
  type LoaderFunctionArgs,
  type RouteObject,
} from 'react-router';
import { ErrorApi, pedir } from '../api';
import { ErrorRuta } from '../rutas/error';
import type { Pagina } from '../tipos';

const ESTADOS = ['PENDIENTE', 'ENVIADO', 'ENTREGADO', 'CANCELADO'];

interface Pedido {
  pedidoId: number;
  cliente: string;
  estado: string;
}

async function loader({ request }: LoaderFunctionArgs) {
  return pedir<Pagina<Pedido>>('/pedidos?tamano=5', { signal: request.signal });
}

async function action({ request }: ActionFunctionArgs) {
  const formulario = await request.formData();
  const id = formulario.get('id');
  try {
    await pedir(`/pedidos/${id}`, {
      metodo: 'PATCH',
      cuerpo: { estado: formulario.get('estado') },
    });
    return { error: null };
  } catch (error) {
    if (error instanceof ErrorApi && error.status === 401) {
      return { error: 'Inicia sesión para cambiar pedidos' };
    }
    throw error;
  }
}

function FilaPedido({ pedido }: { pedido: Pedido }) {
  const fetcher = useFetcher<typeof action>();
  // Mientras se envía, se pinta lo que el usuario acaba de elegir.
  const estado = fetcher.formData?.get('estado')?.toString() ?? pedido.estado;

  return (
    <tr>
      <td>{pedido.pedidoId}</td>
      <td>{pedido.cliente}</td>
      <td>
        <fetcher.Form method="post">
          <input type="hidden" name="id" value={pedido.pedidoId} />
          <select
            name="estado"
            value={estado}
            aria-label={`Estado del pedido ${pedido.pedidoId}`}
            onChange={(e) => fetcher.submit(e.currentTarget.form)}
          >
            {ESTADOS.map((e) => (
              <option key={e}>{e}</option>
            ))}
          </select>
          {fetcher.state === 'submitting' && ' guardando…'}
          {fetcher.data?.error && <span className="error"> {fetcher.data.error}</span>}
        </fetcher.Form>
      </td>
    </tr>
  );
}

function Pedidos() {
  const { contenido } = useLoaderData<typeof loader>();
  return (
    <main>
      <h1>Pedidos</h1>
      <table>
        <tbody>
          {contenido.map((p) => (
            <FilaPedido key={p.pedidoId} pedido={p} />
          ))}
        </tbody>
      </table>
    </main>
  );
}

export const rutas: RouteObject[] = [
  {
    path: '/',
    loader,
    action,
    Component: Pedidos,
    ErrorBoundary: ErrorRuta,
    HydrateFallback: () => <p>Cargando…</p>,
  },
];

Cada fila tiene su useFetcher y por tanto su propio estado: puedes cambiar varias a la vez. El valor mostrado sale de fetcher.formData mientras se envía, y de los datos revalidados cuando termina. No hay que deshacer nada: si la API falla, formData se vacía, y pedido.estado sigue siendo el real. Lo comprobamos sin sesión: el <select> muestra CANCELADO durante el envío y vuelve solo a ENTREGADO cuando la API rechaza el cambio. La API exige estar autenticado, y por eso el catch traduce el 401.

Ejercicio 7: Zona de administración

Crea una ruta /admin con un middleware que redirija a /entrar si no hay sesión, y lance un error 403 si el rol no es ADMIN. El loader debe leer la sesión del contexto, y el error debe mostrarse sin que desaparezca el marco de la aplicación.

Ver solución
import {
  createContext,
  data,
  Link,
  Outlet,
  redirect,
  useLoaderData,
  type LoaderFunctionArgs,
  type MiddlewareFunction,
  type RouteObject,
} from 'react-router';
import { ErrorRuta } from '../rutas/error';
import { leerSesion } from '../sesion';
import type { Sesion } from '../tipos';

const contextoSesion = createContext<Sesion | null>(null);

const exigirAdmin: MiddlewareFunction = ({ context }) => {
  const sesion = leerSesion();
  if (!sesion) throw redirect('/entrar');
  if (sesion.usuario.rol !== 'ADMIN') {
    throw data('Esta zona es solo para administradores', { status: 403 });
  }
  context.set(contextoSesion, sesion);
};

function cargarAdmin({ context }: LoaderFunctionArgs) {
  return { nombre: context.get(contextoSesion)?.usuario.nombre };
}

function Admin() {
  const { nombre } = useLoaderData<typeof cargarAdmin>();
  return <p>Hola, {nombre}. Esto solo lo ve un administrador.</p>;
}

function Marco() {
  return (
    <main>
      <p className="fila">
        <Link to="/">Inicio</Link> <Link to="/admin">Administración</Link>
      </p>
      <Outlet />
    </main>
  );
}

export const rutas: RouteObject[] = [
  {
    path: '/',
    Component: Marco,
    children: [
      { index: true, element: <p>Zona pública</p> },
      { path: 'entrar', element: <p>Formulario de acceso</p> },
      {
        path: 'admin',
        middleware: [exigirAdmin],
        ErrorBoundary: ErrorRuta,
        loader: cargarAdmin,
        Component: Admin,
      },
    ],
  },
];

redirect y data se lanzan igual, pero tienen efectos distintos: el primero navega y el segundo llega a la frontera de error. Al poner ErrorBoundary en la propia ruta /admin, el 403 se pinta dentro de Marco, y los enlaces siguen disponibles. Aplicación con los enlaces Inicio y Administración sobre una tarjeta con Error 403

Figura 20. El 403 se pinta dentro de Marco.

Se ha probado con los tres casos: como ADMIN entra, como USER ve el error y sin sesión llega a /entrar. Recuerda la advertencia de la sección 14: esto solo oculta la pantalla, y la API debe rechazar por su cuenta cualquier operación de administrador. En React Router 7 añade future.v8_middleware y amplía interface Future, como en la sección 20.

Ejercicio 8: Buscador mientras escribes

Haz un buscador de empleados que busque al dejar de escribir durante 300 ms, sin botón. La búsqueda debe quedar en la URL, pero las pulsaciones no deben llenar el historial.

Ver solución
import { useEffect, useRef, type ChangeEvent } from 'react';
import {
  Form,
  useLoaderData,
  useNavigation,
  useSubmit,
  type LoaderFunctionArgs,
  type RouteObject,
} from 'react-router';
import { pedir } from '../api';
import type { Empleado, Pagina } from '../tipos';

async function loader({ request, url }: LoaderFunctionArgs) {
  const q = url.searchParams.get('q') ?? '';
  const consulta = new URLSearchParams({ q, tamano: '5' });
  const resultado = await pedir<Pagina<Empleado>>(`/empleados?${consulta}`, {
    signal: request.signal,
  });
  return { resultado, q };
}

function Buscador() {
  const { resultado, q } = useLoaderData<typeof loader>();
  const enviar = useSubmit();
  const navegacion = useNavigation();
  const temporizador = useRef<number | undefined>(undefined);

  useEffect(() => () => clearTimeout(temporizador.current), []);

  function alEscribir(evento: ChangeEvent<HTMLInputElement>) {
    const formulario = evento.currentTarget.form;
    clearTimeout(temporizador.current);
    temporizador.current = window.setTimeout(() => {
      // replace: las pulsaciones no llenan el historial.
      enviar(formulario, { replace: true });
    }, 300);
  }

  return (
    <main aria-busy={navegacion.state !== 'idle'}>
      <h1>Buscador</h1>
      <Form role="search">
        <input
          type="search"
          name="q"
          defaultValue={q}
          aria-label="Buscar"
          onChange={alEscribir}
        />
      </Form>
      <p>{resultado.total} resultados</p>
      <ul>
        {resultado.contenido.map((e) => (
          <li key={e.empId}>
            {e.nombre} {e.apellidos}
          </li>
        ))}
      </ul>
    </main>
  );
}

export const rutas: RouteObject[] = [
  { path: '/', loader, Component: Buscador, HydrateFallback: () => <p>Cargando…</p> },
];

useSubmit envía un formulario por código, igual que si lo hubiera hecho el usuario. Con replace: true cada búsqueda sustituye la entrada del historial: al escribir garcia, history.length no aumentó. El temporizador se guarda en una referencia (módulo 10) para cancelarlo con cada pulsación, y el efecto lo limpia al desmontar. Si llega una búsqueda nueva con otra en marcha, el router cancela la anterior con request.signal, y por tanto no hay condiciones de carrera.

Ejercicio 9 (propuesto): Aplicación en la versión 7

Crea una copia del proyecto del módulo, instala react-router@7 y react@19.1, y haz que funcione. Comprueba que react-router-dom no hace falta. Cambia el <ViewTransition>, si lo has usado, por la prop viewTransition.

Ejercicio 10 (propuesto): Pantalla de departamentos completa

Añade a /departamentos las operaciones de crear, editar y borrar. El borrado debe usar useFetcher y mostrar el error 409 de un departamento con empleados.

Ejercicio 11 (propuesto): Filtros combinados

Añade a /empleados un filtro por departamento con un <select> que use el parámetro deptId de la API. Haz que al cambiar cualquier filtro se vuelva a la página 1, pero que al paginar se conserven los filtros.

Ejercicio 12 (propuesto): De Declarativo a Datos

Toma la aplicación de la sección 18 y conviértela al modo Datos: elimina el efecto, crea un loader, y añade HydrateFallback y una frontera de error.


Resumen

Concepto API Idea clave
Instalación npm install react-router Sin react-router-dom, que no tiene versión 8
Requisitos React 19.2.7, Node 22.22 Con versiones anteriores, react-router@7
Arranque createBrowserRouter, RouterProvider El proveedor se importa de react-router/dom; el router se crea fuera de los componentes
Modos Declarativo, Datos, Framework Son acumulativos; este módulo usa Datos
Enlaces Link, NavLink end en el de inicio; active y pending son automáticas
Anidación children, Outlet, index Cada nivel se pinta en el Outlet de su padre
Rutas sin path { children } Agrupan diseño, errores o middleware sin tocar la URL
Enlaces relativos to="..", relative="path" .. sube una ruta, no un segmento
Parámetros useParams Siempre cadenas; hay que validarlos
Navegar useNavigate, Navigate replace para no dejar rastro en el historial
Datos loader, useLoaderData Se carga antes de pintar y en paralelo
Cancelación request.signal Pásalo a fetch; el router evita las carreras
Revalidación shouldRevalidate, useRevalidator Tras una action o un cambio de ?búsqueda se repiten los loader
Carga pendiente useNavigation idle, loading y submitting
URL como estado useSearchParams, <Form> GET Lo que define la vista va en la URL
Escritura action, <Form method="post"> Devuelve lo esperado, lanza lo inesperado
Resultado redirect, useActionData Sin redirect la URL no cambia
Sin navegar useFetcher Un estado por instancia; formData para ser optimista
Errores ErrorBoundary, useRouteError, isRouteErrorResponse, data La frontera más cercana; una ruta sin path salva la cabecera
Protección middleware, createContext, redirect Corre antes que los loader, pero solo el servidor protege de verdad
División de código lazy Carga también el loader; exporta Component
Datos lentos Promesa sin await, <Await>, <Suspense> La pantalla no espera a lo accesorio
Despliegue basename, try_files Devuelve index.html en toda ruta