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 yOutlet - 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
loadery saber exactamente cuándo se vuelven a ejecutar - Enseñar estados de carga con
useNavigation - Guardar filtros y paginación en la URL con
useSearchParamsy<Form>de tipo GET - Enviar datos con
actiony<Form>, y mostrar los errores de validación de la API - Hacer cambios sin navegar con
useFetcher - Colocar fronteras de error por ruta con
ErrorBoundaryyuseRouteError - Proteger rutas con
middlewarey entender qué protege de verdad y qué no - Dividir el código por rutas con
lazyy 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
reactyreact-dom19.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: instalareact-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.tsinstalados 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.
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:
- Un componente de enlace que renderiza un
<a>normal, intercepta el clic conpreventDefaulty llama apushState. - Un emparejador que compara la URL actual con tu lista de rutas.
- 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.
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-domno tiene versión 8. Su etiquetalatesten npm sigue siendo la 7.18.5, así quenpm install react-router-domno da error: instala tan tranquilo la versión 7, y esa arrastra a su vezreact-router7. Si lo añades a un proyecto conreact-router8 acabas con dos routers distintos. Si vesreact-router-domen 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.10no sirve y19.2.7,19.2.9o19.3.1sí.
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:

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.Componentindica qué componente pinta la ruta. También existeelement, 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 unLinkque sabe si está activo: añade la claseactivey el atributoaria-current="page". Por eso en el CSS hay una regla.cabecera a.active.enden elNavLinkde 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:
NavLinktambién admite funciones enclassName,styleychildren, que reciben{ isActive, isPending }. Sirven cuando no quieres depender de la claseactive:<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 />.
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?/empleadosencaja con y sin idioma. - Un comodín final
*recoge el resto de la URL enparams['*']. generatePathhace lo contrario: construye la URL a partir del patrón, y codifica los caracteres especiales.
Navegar por código
<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
useNavigatedentro de unonClick, 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. ReservauseNavigatepara lo que ocurre después de algo, y prefiere devolverredirecten unaaction, 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:
pediracepta unsignal, para poder cancelar la petición si el usuario se va a otra pantalla.- Si la respuesta no es correcta, lanza un
ErrorApicon el código, el mensaje y losdetallesde validación que devuelve la API. - Si llega un 401 con sesión guardada, lanza un
redirect. Es un detalle propio de React Router:redirectdevuelve un objetoResponse, y unloadero unaactionlo pueden lanzar conthrowdesde cualquier profundidad.
💡 Consejo:
VITE_APIes 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:
- La URL cambia.
- Se pinta el componente nuevo, vacío o con un “Cargando…”.
- Se ejecuta el efecto y sale la petición.
- 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.
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
loaderrecibe un objeto conparams,request,context,urlypattern.patternes el patrón sin rellenar, como/empleados/:id, útil para los registros. useLoaderData<typeof loader>()devuelve lo que devolvió elloader. El genéricotypeof loaderhace que TypeScript deduzca el tipo desde la función, y así no lo tienes que repetir.requestes un objetoRequestestándar. Conrequest.signalla 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 unloaderque no es el de esa ruta, TypeScript no se quejará. Por eso conviene que elloadery 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:

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-busyen<main>anuncia a los lectores de pantalla que la zona está cambiando.- El
NavLinkhacia el destino recibe la clasependingmientras carga, aunque no hayas escrito nada. La regla.cabecera a.pendingdel 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 propuseTransitionspara decidirlo: confalseno usa transiciones y contruetambién envuelve las navegaciones deLinkyForm, y las muestra conuseOptimistic. 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>
);
}

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 unNumber(...)sin comprobar te puede darNaN.
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:
<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.navigation.statepasa a'submitting'. El botón muestra “Guardando…” y se desactiva.- Se ejecuta la
actionde esa ruta.request.formData()le entrega unFormDatacon los campos, que se identifican por suname. - Si todo va bien, devuelve
redirect. El router navega, y losloaderactivos se ejecutan de nuevo. - Si la API responde 422, la
actiondevuelve los errores y no cambia de URL.useActionDatalos recibe y se pintan bajo cada campo.

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
redirecttambié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:

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.
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 });
}

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:

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:

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
onClickfalla, 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:

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
middlewarees una función estable ycontextsiempre tiene su tipo. Si usas una versión anterior: en React Router 7.18 pasafuture: { v8_middleware: true }acreateBrowserRouter, como pide su documentación, y amplíainterface Futurepara quecontextno seaany. 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:
registrarusaawait next()para ejecutar código antes y después. Sin llamar anext, el router lo hace por ti al terminar la función. Solo lo necesitas si quieres hacer algo a la vuelta.cargarSesionguarda la sesión en el contexto del router.createContextcrea una clave con tipo, ycontext.setycontext.getescriben y leen. Es lo que nos permite escribir enraiz.tsxcontext.get(contextoSesion).exigirSesionlanza unredirectsi no hay sesión. Al lanzarlo, no se ejecuta nada de lo que hay por debajo: ni losloaderni laaction.
⚠️ Importante: Este
createContextes el dereact-router, y no tiene nada que ver con elcreateContextde React del módulo 11. Son dos cosas con el mismo nombre: el primero pasa datos entremiddleware,loaderyaction, 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:
localStoragees 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 cookieHttpOnly, 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>.

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,idycaseSensitivedeben ir en la ruta, porque el router los necesita para emparejar antes de descargar nada.middlewaretampoco se admite en la forma con función, aunque sí en la forma con objeto. Si exportasComponenty también unadefaultque no usas, no pasa nada, pero ladefaultno cuenta:lazyno 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>
</>
);
}

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
loaderdevuelve directamente una promesa, la función asíncrona la espera, como cualquier otra. errorElementrecoge el rechazo de la promesa. Sin él, el error sube a la frontera de error de la ruta.- Los datos que esperas con
awaiten elloaderestán listos al pintar. Los que devuelves como promesa llegan después. Puedes mezclarlos en un mismoloader.
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 propviewTransitionde<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:
createRoutesFromElementsconvierte tus<Route>en objetos de ruta, y puedes ir añadiendoloaderpoco 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.idno esstring | undefined, sinostring:
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
loaderse ejecuta en el servidor, de modo que puede acceder a una base de datos directamente.clientLoaderes el que se ejecuta en el navegador. - Se añaden
@react-router/devy@react-router/nodecomo dependencias, y el proyecto se construye conreact-router build, que generabuild/clientybuild/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 elmiddlewarese ejecutó igual:registrarescribió en la consola, y/empleados/100/editarredirigió 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:
- Sube Node a 22.22 o superior, y React a 19.2.7 o superior.
- Si tienes
react-router-dom, cambia las importaciones:RouterProviderareact-router/dom, y todo lo demás areact-router. Luego ejecutanpm uninstall react-router-dom. - Cambia
matches[i].datapormatches[i].loaderData. - Quita
future.v8_middlewarey la ampliación deinterface Future. - 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.

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 },
},
],
},
],
},
];

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> },
];

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