Módulo 11: Router avanzado
Objetivos del módulo
- Seguir paso a paso una navegación y saber qué se ejecuta en cada fase
- Proteger rutas con guards funcionales:
canActivate,canActivateChildycanDeactivate - Escribir guards parametrizables y asíncronos sin perder el contexto de inyección
- Decidir entre
canMatchycanActivate, y usarcanMatchpara servir dos pantallas en la misma URL - Cargar datos antes de entrar en una ruta con resolvers y tratar sus errores
- Redirigir con
UrlTreeyRedirectCommand, devolviéndolo o lanzándolo - Mostrar varias zonas independientes con salidas con nombre
- Ajustar el comportamiento del router con
withRouterConfigy controlar cuándo se repiten guards y resolvers - Precargar código perezoso con
withPreloadingy una estrategia propia - Escuchar
router.eventsy usarrouter.currentNavigation()para mostrar una barra de carga - Animar los cambios de página con
withViewTransitions - Liberar los inyectores de ruta con
withAutoCleanupInjectors - Declarar los datos en la propia ruta con
withRouterResources, y conocer su estado real
1. Qué pasa entre el clic y la pantalla nueva
En el módulo 10 usaste el router como un sistema que relaciona URL y componentes. Visto desde fuera, parece instantáneo: pulsas un enlace y aparece otra pantalla. Por dentro, cada navegación es una secuencia de pasos, y casi todo este módulo consiste en intervenir en alguno de ellos.
Conviene tener el mapa delante antes de entrar en detalle:
Figura 1. Una navegación pasa por cinco fases. En naranja, lo que puedes escribir tú para decidir o preparar la entrada en la ruta.
- Reconocer. El router compara la URL con la tabla de rutas, aplica redirecciones y ejecuta
canMatchpara saber qué ruta encaja. - Comprobar. Ya sabe adónde va. Pregunta a los guards si se puede salir de lo que hay y entrar en lo nuevo.
- Resolver. Ejecuta los resolvers, descarga el componente perezoso y espera a los datos que la ruta haya declarado.
- Activar. Actualiza la URL del navegador, crea los componentes y les entrega sus entradas.
- Confirmar. Emite
NavigationEndy cambia el título de la página.
Lo importante es esto: hasta la fase 4 la pantalla no cambia. Mientras un guard o un resolver espera a la red, el usuario sigue viendo la página anterior. Esa es la ventaja de estas herramientas (nunca entras en una pantalla a medias) y también su riesgo (si tarda, parece que el clic no ha hecho nada). Volveremos a ello en las secciones 3 y 8.
La secuencia puede acabar antes de tiempo de tres maneras: con NavigationCancel si algo la detiene de forma controlada, con NavigationError si algo falla, y con NavigationSkipped si el router decide que no hay nada que hacer.
La aplicación de este módulo
Todos los ejemplos pertenecen a la aplicación EMPRESA y se apoyan en la API de ejemplo (descarga recursos/empresa-api.zip, arráncala con node server.mjs). Guardamos su dirección en una constante:
// src/app/config.ts
export const API = 'http://localhost:3000/api';
Y estos son los tipos de los datos que devuelve:
// src/app/modelos.ts
export interface Empleado {
empId: number;
nombre: string;
apellidos: string;
email: string;
oficio: string;
salario: number;
deptId: number;
departamento: string;
}
export interface Departamento {
deptId: number;
nombre: string;
localidad: string;
presupuesto: number;
}
export interface Pedido {
pedidoId: number;
cliente: string;
estado: string;
fechaPedido: string;
}
/** Forma común de los listados de la API. */
export interface Pagina<T> {
contenido: T[];
total: number;
}
Vamos a pedir datos con HttpClient, que funciona sin configurar nada y que verás a fondo en el módulo 12. De momento basta con saber que http.get<Empleado>(url) devuelve un Observable, un valor que llega más tarde. Los operadores pipe, map y catchError se explican en el módulo 14.
Necesitamos además un servicio de sesión. Los guards consultarán este servicio para saber quién está dentro:
// src/app/sesion.ts
import { HttpClient } from '@angular/common/http';
import { computed, inject, Injectable, signal } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import { API } from './config';
export type Rol = 'ADMIN' | 'USER';
export interface Usuario {
email: string;
nombre: string;
rol: Rol;
}
interface RespuestaLogin {
token: string;
usuario: Usuario;
expiraEn: number;
}
const CLAVE = 'empresa.sesion';
@Injectable({ providedIn: 'root' })
export class Sesion {
private readonly http = inject(HttpClient);
private readonly datos = signal<RespuestaLogin | null>(leerGuardada());
readonly usuario = computed(() => this.datos()?.usuario ?? null);
readonly activa = computed(() => this.datos() !== null);
tieneRol(...roles: Rol[]): boolean {
const usuario = this.usuario();
return usuario !== null && roles.includes(usuario.rol);
}
async iniciar(email: string, password: string): Promise<void> {
const respuesta = await firstValueFrom(
this.http.post<RespuestaLogin>(`${API}/auth/login`, { email, password }),
);
sessionStorage.setItem(CLAVE, JSON.stringify(respuesta));
this.datos.set(respuesta);
}
cerrar(): void {
sessionStorage.removeItem(CLAVE);
this.datos.set(null);
}
}
function leerGuardada(): RespuestaLogin | null {
const texto = sessionStorage.getItem(CLAVE);
return texto ? (JSON.parse(texto) as RespuestaLogin) : null;
}
💡 Consejo: Aquí usamos
@Injectable({ providedIn: 'root' })para que el código funcione igual en Angular 21 y 22. En Angular 22 puedes escribir@Service(), como viste en el módulo 08.
El proyecto final del curso sustituirá este servicio por uno completo con interceptor de JWT. Aquí solo necesitamos lo justo para decidir si alguien puede pasar.
2. Guards funcionales
Un guard (guardia, centinela) es una función que el router llama para preguntar: ¿sigo adelante? Es una función corriente, con una particularidad: se ejecuta en un contexto de inyección, así que puede llamar a inject() directamente.
Puede responder de varias formas. El tipo MaybeAsync<GuardResult> lo resume:
| Respuesta | Efecto |
|---|---|
true | Continúa |
false | Cancela la navegación y se queda donde estaba |
UrlTree | Cancela y redirige a esa URL |
RedirectCommand | Cancela y redirige, con opciones de navegación (sección 5) |
Promise u Observable de lo anterior | Espera al resultado |
Un UrlTree es la URL ya analizada, como objeto. Se obtiene con router.parseUrl('/login') o router.createUrlTree(['/login'], { queryParams }).
canActivate: ¿puedes entrar?
El caso clásico: si no hay sesión, se manda al usuario a la pantalla de inicio y se recuerda adónde quería ir.
// src/app/guards.ts
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { Sesion } from './sesion';
export const sesionGuard: CanActivateFn = (_ruta, estado) => {
const sesion = inject(Sesion);
const router = inject(Router);
if (sesion.activa()) {
return true;
}
return router.createUrlTree(['/login'], { queryParams: { volverA: estado.url } });
};
Recibe dos parámetros: ruta (un ActivatedRouteSnapshot con los parámetros y los datos de la ruta de destino) y estado (el RouterStateSnapshot, cuya propiedad url es la URL completa a la que se quería ir). El guion bajo de _ruta indica que no la usamos.
Se aplica en la tabla de rutas. Al ponerlo en la ruta padre protege a todas sus hijas:
// src/app/app.routes.ts (fragmento)
{
path: 'empleados',
canActivate: [sesionGuard],
children: [
{ path: '', component: ListaEmpleados },
{ path: ':id', component: FichaEmpleado },
],
},
La pantalla de inicio de sesión recoge volverA como una entrada más, gracias a withComponentInputBinding(), y al terminar retoma la navegación interrumpida:
// src/app/login.ts
import { Component, inject, input } from '@angular/core';
import { Router } from '@angular/router';
import { Sesion } from './sesion';
@Component({
selector: 'app-login',
template: `
<h2>Iniciar sesión</h2>
@if (volverA(); as destino) {
<p class="aviso">Necesitas iniciar sesión para entrar en <code>{{ destino }}</code>.</p>
}
<button type="button" (click)="entrar('ana.garcia@empresa.es')">Entrar como Ana (ADMIN)</button>
<button type="button" (click)="entrar('luis.martinez@empresa.es')">Entrar como Luis (USER)</button>
`,
})
export class Login {
private readonly sesion = inject(Sesion);
private readonly router = inject(Router);
readonly volverA = input<string>();
protected async entrar(email: string) {
await this.sesion.iniciar(email, 'secreto');
await this.router.navigateByUrl(this.volverA() ?? '/');
}
}

Figura 2. Al pedir /empleados/100?orden=nombre sin sesión, el guard redirige a /login?volverA=.... Tras entrar, se llega a esa misma URL con sus parámetros.
Hemos entrado con el botón de Ana y la aplicación ha terminado en /empleados/100?orden=nombre. Usamos navigateByUrl y no navigate porque volverA ya es una URL completa con sus parámetros.
⚠️ Importante: Un guard solo decide qué muestra la interfaz. Todo el código de tu aplicación llega al navegador, y cualquiera puede leerlo o modificar su ejecución. La seguridad real está en el servidor: en la API de EMPRESA, un
DELETEsin rolADMINdevuelve 403 aunque el usuario se haya saltado el guard. Piensa en los guards como en la señal de “solo personal autorizado”: ordenan el paso, pero la cerradura está en la puerta.
Guards parametrizables: funciones que devuelven guards
¿Y si una ruta exige ADMIN y otra admite más roles? No hace falta escribir un guard por rol. Como un guard es una función, otra función puede fabricarlo:
export function conRol(...roles: Rol[]): CanActivateFn {
return () => {
const sesion = inject(Sesion);
const router = inject(Router);
return sesion.tieneRol(...roles) || router.parseUrl('/sin-permiso');
};
}
{ path: 'nominas', component: Nominas, canActivate: [sesionGuard, conRol('ADMIN')] },
Fíjate en dónde está cada cosa. conRol('ADMIN') se ejecuta una vez, al cargar app.routes.ts, y fuera de cualquier contexto de inyección; por eso no puede llamar a inject(). La función que devuelve se ejecuta en cada navegación, y ahí sí puede. Luis (rol USER) acaba en /sin-permiso al pedir /nominas; Ana entra.
La expresión a || b aprovecha una propiedad de JavaScript: si tieneRol devuelve true, el resultado es true; si no, el UrlTree. Si prefieres, escríbelo con un if.
Varios guards en una ruta: empiezan a la vez
El array de canActivate invita a pensar en una cadena: primero uno, luego otro. No es así. Lo comprobamos con dos guards de distinta duración:
function lento(nombre: string, ms: number, resultado: boolean): CanActivateFn {
return async () => {
console.log(`[orden] ${nombre} empieza`);
await new Promise((r) => setTimeout(r, ms));
console.log(`[orden] ${nombre} acaba: ${resultado}`);
return resultado;
};
}
{ path: 'orden', component: Vacia, canActivate: [lento('lento', 300, false), lento('rapido', 50, true)] },
Salida real en la consola:
[orden] lento empieza
[orden] rapido empieza
[orden] rapido acaba: true
[orden] lento acaba: false
Los dos empiezan a la vez. El router no espera a que acabe uno para lanzar el siguiente. Lo que sí respeta es la prioridad: decide según el primero del array que no devuelva true. Por eso aquí la navegación se cancela, aunque rapido haya terminado antes con true. Si invertimos la situación (primero tarda 300 ms y devuelve true, segundo tarda 50 ms y devuelve false) también se cancela, porque ninguno de los dos puede dejar pasar por su cuenta.
La consecuencia práctica: un guard no puede depender de lo que haga otro del mismo array. En [sesionGuard, conRol('ADMIN')] funciona porque cada uno lee el estado de la sesión por su cuenta. Si dos comprobaciones deben ir en orden, júntalas en una sola función.
canActivateChild: una comprobación por cada hija
canActivate en el padre se ejecuta al entrar en esa rama, pero no vuelve a ejecutarse al moverte entre hijas: de /empleados/100 a /empleados/110 el padre sigue activo. Si la comprobación depende de la hija concreta, usa canActivateChild:
export const trazaHijasGuard: CanActivateChildFn = (_ruta, estado) => {
console.log(`[guard] canActivateChild ${estado.url}`);
return true;
};
{
path: 'empleados',
canActivate: [sesionGuard],
canActivateChild: [trazaHijasGuard],
children: [/* ... */],
},
canDeactivate: ¿puedes salir?
Sirve para el problema contrario: el usuario ha escrito en un formulario y pulsa un enlace sin querer. A diferencia de los anteriores, recibe el componente que se va a abandonar y puede preguntarle. Para que sea reutilizable, definimos una interfaz:
export interface ConCambios {
hayCambiosSinGuardar(): boolean;
}
export const sinCambiosGuard: CanDeactivateFn<ConCambios> = (componente) => {
if (!componente.hayCambiosSinGuardar()) {
return true;
}
return confirm('Hay cambios sin guardar. ¿Quieres salir igualmente?');
};
@Component({
selector: 'app-editar-departamento',
template: `
<h2>Editar {{ departamento().nombre }}</h2>
<label>Localidad
<input [value]="departamento().localidad" (input)="tocado.set(true)" />
</label>
<button type="button" (click)="tocado.set(false)">Guardar</button>
@if (tocado()) { <p class="aviso">Tienes cambios sin guardar.</p> }
`,
})
export class EditarDepartamento implements ConCambios {
readonly departamento = input.required<Departamento>();
protected readonly tocado = signal(false);
hayCambiosSinGuardar() {
return this.tocado();
}
}
{
path: 'departamentos/:id/editar',
component: EditarDepartamento,
canDeactivate: [sinCambiosGuard],
resolve: { departamento: departamentoResolver }, // sección 4
},
Si cancelas el confirm, navigateByUrl termina con false y sigues en el formulario. Si aceptas, la navegación continúa.
⚠️ Importante:
canDeactivatesolo vigila las navegaciones del router. No se entera de que el usuario cierra la pestaña o recarga: para eso está el eventobeforeunloaddel navegador.
Orden entre los distintos tipos
En una misma navegación, el router comprueba primero todos los canDeactivate (de la ruta más profunda hacia arriba) y luego, de padre a hija, canActivateChild y canActivate. Si uno rechaza, no se piden datos: los resolvers van después de todos los guards.
Guards asíncronos y el contexto perdido
Un guard puede ser async, pero hay una trampa. inject() solo funciona mientras dura la parte síncrona de la función. Después del primer await, el contexto ya no existe:
function esperar(ms: number) {
return new Promise<void>((r) => setTimeout(r, ms));
}
// MAL: inject() después de un await
export const injectTardeGuard: CanActivateFn = async () => {
await esperar(10);
const router = inject(Router);
return router.parseUrl('/');
};
La navegación falla con este error real (recortado):
NG0203: The `Router` token injection failed. `inject()` function must be called from an injection context ...
La solución es inyectar al principio, antes de cualquier await:
// BIEN
export const injectPrimeroGuard: CanActivateFn = async () => {
const router = inject(Router);
await esperar(10);
return router.parseUrl('/');
};
💡 Consejo: Si tienes guards antiguos escritos como clases,
mapToCanActivate,mapToCanActivateChild,mapToCanMatchymapToResolvelos convierten en funciones, y así puedes migrarlos poco a poco. El código nuevo se escribe con funciones. El antiguocanLoadestá obsoleto: se sustituye porcanMatch.
3. canMatch: la ruta existe solo si te conviene
canActivate se ejecuta cuando el router ya ha elegido la ruta. canMatch se ejecuta mientras la elige. Parece un matiz, pero cambia lo que ocurre al responder que no:
Figura 3. canMatch en false significa “esta ruta no es para ti, sigue buscando”. canActivate en false significa “se acabó”.
Con eso podemos servir pantallas distintas en la misma URL, sin un @if gigante:
export const esAdminMatch: CanMatchFn = () => inject(Sesion).tieneRol('ADMIN');
export const haySesionMatch: CanMatchFn = () => inject(Sesion).activa();
{ path: 'panel', component: PanelAdmin, canMatch: [esAdminMatch] },
{ path: 'panel', component: PanelUsuario, canMatch: [haySesionMatch] },
{ path: 'panel', redirectTo: '/login' },
El orden importa, porque el router prueba las rutas de arriba abajo. Comprobado en el laboratorio:
| Usuario | /panel termina en |
|---|---|
Ana (ADMIN) | /panel, “Panel de administración” |
Luis (USER) | /panel, “Mi panel” |
| Sin sesión | /login |
canMatch recibe (ruta, segmentos): la configuración de la ruta y los trozos de URL que quedan por encajar. También admite devolver un UrlTree o un RedirectCommand para redirigir.
Lo que canMatch evita descargar
Otra ventaja: si canMatch rechaza una ruta perezosa, su código no se descarga, porque el router ni siquiera ha llegado a elegirla:
{
path: 'zona-secreta',
canMatch: [denegadoMatch], // devuelve false
loadComponent: () => import('./secreto/secreto').then((m) => m.Secreto),
},
{ path: '**', component: NoEncontrado },
Al navegar a /zona-secreta se muestra NoEncontrado, la URL no cambia y en la pestaña Red no aparece ningún chunk nuevo. Con canActivate, en cambio, el usuario vería una navegación cancelada, y sabría que esa ruta existe.
⚠️ Importante: Esto no convierte
canMatchen una medida de seguridad. Si activas la precarga, la sección 7 demuestra quePreloadAllModulesdescarga ese fichero sin consultarcanMatch. Nunca guardes secretos en el código de la aplicación.
Cuándo usar cada uno
| Pregunta | canMatch | canActivate |
|---|---|---|
| ¿Cuándo se ejecuta? | Al elegir la ruta | Con la ruta ya elegida |
Al devolver false | Prueba la siguiente | Cancela la navegación |
| Si la ruta es perezosa y responde que no | No se descarga nada (salvo con precarga) | loadChildren ya se ha descargado al reconocer la ruta; loadComponent no llega a descargarse |
| Uso típico | Variantes por rol, banderas de funcionalidad, pruebas A/B | Sesión y permisos, con redirección |
📘 Concepto: Usa
canActivatecuando la pregunta sea “¿te dejo entrar?”, ycanMatchcuando sea “¿qué pantalla te corresponde en esta URL?”.
Ten en cuenta una consecuencia: canMatch se evalúa solo al navegar. Si Luis pasa a ser administrador mientras está en /panel, seguirá viendo su pantalla hasta la siguiente navegación.
4. Resolvers: los datos antes que la pantalla
En el flujo habitual, el componente aparece y luego pide sus datos, así que hay que dibujar un estado de carga. Un resolver invierte el orden: el router obtiene los datos primero y entra en la ruta con todo preparado. El componente no necesita pensar en la carga, ni en un valor undefined.
// src/app/resolvers.ts
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { inject } from '@angular/core';
import { RedirectCommand, ResolveFn, Router } from '@angular/router';
import { catchError, map, of, throwError } from 'rxjs';
import { API } from './config';
import { Departamento, Empleado } from './modelos';
export const departamentoResolver: ResolveFn<Departamento> = (ruta) =>
inject(HttpClient).get<Departamento>(`${API}/departamentos/${ruta.paramMap.get('id')}`);
export const empleadoResolver: ResolveFn<Empleado> = (ruta) => {
const http = inject(HttpClient);
const router = inject(Router);
const id = ruta.paramMap.get('id');
return http.get<Empleado>(`${API}/empleados/${id}`).pipe(
catchError((error: HttpErrorResponse) => {
if (error.status === 404) {
return of(new RedirectCommand(router.parseUrl('/no-encontrado'), { skipLocationChange: true }));
}
return throwError(() => error);
}),
);
};
Se declara en la ruta con la clave resolve. El nombre que elijas (empleado) será el de la entrada del componente:
{ path: ':id', component: FichaEmpleado, resolve: { empleado: empleadoResolver } },
@Component({
selector: 'app-ficha-empleado',
template: `
<h2>{{ empleado().nombre }} {{ empleado().apellidos }}</h2>
<dl>
<dt>Oficio</dt><dd>{{ empleado().oficio }}</dd>
<dt>Departamento</dt><dd>{{ empleado().departamento }}</dd>
<dt>Salario</dt><dd>{{ empleado().salario }} €</dd>
</dl>
`,
})
export class FichaEmpleado {
readonly empleado = input.required<Empleado>();
}
input.required es seguro aquí: el router no crea el componente hasta tener el dato. Eso exige withComponentInputBinding() en provideRouter, como viste en el módulo 10. Sin esa función, tendrías que leer inject(ActivatedRoute).snapshot.data['empleado'].
Si el resolver falla
Si un resolver falla y nadie captura el error, la navegación acaba mal. Probemos uno sin catchError, con un empleado que no existe:
export const sinSalvarResolver: ResolveFn<Empleado> = (ruta) =>
inject(HttpClient).get<Empleado>(`${API}/empleados/${ruta.paramMap.get('id')}`);
NavigationStart(id: 12, url: '/sin-salvar/1')
NavigationError(id: 12, url: '/sin-salvar/1', error: [object Object])
El usuario se queda donde estaba, sin ninguna explicación, y la promesa de navigateByUrl se rechaza. Por eso empleadoResolver captura el 404 y redirige. El resto de errores los deja pasar, y se pueden tratar todos en un solo lugar con withNavigationErrorHandler:
provideRouter(
routes,
withComponentInputBinding(),
withNavigationErrorHandler((error) => {
console.error(`Fallo al navegar a ${error.url}`, error.error);
return new RedirectCommand(inject(Router).parseUrl('/error'), { skipLocationChange: true });
}),
),
Esta función se ejecuta en un contexto de inyección, y puede devolver un RedirectCommand. Con ella, el mismo caso termina mostrando la pantalla /error, y navigateByUrl se resuelve con true.
Un resolver que no emite
Si el Observable termina sin emitir ningún valor, por ejemplo con EMPTY, la navegación se cancela con el código NoDataFromResolver. Suele ser un error de programación, y es desconcertante porque no aparece nada en la consola.
Títulos dinámicos
La propiedad title de una ruta también admite un ResolveFn<string>. Así, la pestaña del navegador puede mostrar el nombre del empleado:
export const tituloEmpleado: ResolveFn<string> = (ruta) =>
inject(HttpClient)
.get<Empleado>(`${API}/empleados/${ruta.paramMap.get('id')}`)
.pipe(map((e) => `${e.nombre} ${e.apellidos} | EMPRESA`));
{ path: ':id', component: FichaEmpleado, resolve: { empleado: empleadoResolver }, title: tituloEmpleado },
Tras la navegación, document.title vale Ana García López | EMPRESA. Fíjate en que esta versión hace una segunda petición al mismo recurso. En una aplicación real la reutilizarías con una caché en un servicio.
💡 Consejo: ¿Resolver o carga en el componente? El resolver encaja cuando la pantalla no tiene sentido sin esos datos y suelen llegar rápido. Si los datos tardan, mejor entrar enseguida y mostrar un estado de carga: compara las opciones en la sección 12 y en el módulo 13.
5. Redirigir con RedirectCommand
Ya lo has visto varias veces. RedirectCommand es un UrlTree con opciones de navegación. Hace falta cuando la redirección debe comportarse de una forma concreta:
new RedirectCommand(router.parseUrl('/no-encontrado'), {
skipLocationChange: true,
replaceUrl: true,
});
| Opción | Efecto |
|---|---|
skipLocationChange | Muestra el destino sin cambiar la URL de la barra |
replaceUrl | Sustituye la entrada actual del historial en lugar de añadir una |
state | Datos que viajan con la navegación |
info | Datos efímeros, solo para esta navegación |
skipLocationChange es justo lo que queremos para un 404. Si alguien pide /empleados/1, ve “No encontrado”, pero la barra conserva lo que escribió. Así puede corregirlo, y el botón Atrás no le lleva a una URL rota. En el laboratorio, router.url valía /no-encontrado mientras location.pathname seguía sin cambiar.
Un guard y un resolver pueden devolverlo. Es la forma recomendada de redirigir, antes que devolver false y llamar a router.navigate a mano: en este último caso provocarías dos navegaciones independientes que compiten entre sí.
Lanzar RedirectCommand 🆕 v22.2
🆕 Desde Angular 22.2. No existe en la LTS (Angular 21). Allí, un
RedirectCommandlanzado conthrowacaba enNavigationErrory no redirige. Si usas la LTS: devuélvelo conreturn, o añadewithNavigationErrorHandler(({ error }) => (error instanceof RedirectCommand ? error : undefined)), que da el mismo resultado.
Desde 22.2, RedirectCommand también se puede lanzar con throw. Esto simplifica el código cuando la decisión de redirigir se toma en lo más profundo, por ejemplo dentro de un catchError, donde devolver un valor obliga a envolverlo con of(...) y a decir que el tipo admite esa redirección:
export const empleadoLanzaResolver: ResolveFn<Empleado> = (ruta) => {
const http = inject(HttpClient);
const router = inject(Router);
return http.get<Empleado>(`${API}/empleados/${ruta.paramMap.get('id')}`).pipe(
catchError((error: HttpErrorResponse) => {
if (error.status === 404) {
throw new RedirectCommand(router.parseUrl('/no-encontrado'));
}
throw error;
}),
);
};
También funciona en guards, síncronos o asíncronos, y en canMatch:
export const lanzaGuard: CanActivateFn = () => {
throw new RedirectCommand(inject(Router).parseUrl('/sin-permiso'));
};
export const lanzaAsyncGuard: CanActivateFn = async () => {
const router = inject(Router);
await esperar(10);
throw new RedirectCommand(router.parseUrl('/sin-permiso'));
};
Hemos probado la misma ruta en las dos versiones. Eventos reales:
Angular 22.2
NavigationStart(id: 2, url: '/lanza')
NavigationCancel(id: 2, url: '/lanza')
NavigationStart(id: 3, url: '/sin-permiso')
NavigationEnd(id: 3, url: '/sin-permiso', urlAfterRedirects: '/sin-permiso')
Angular 21.2
NavigationStart(id: 2, url: '/lanza')
NavigationError(id: 2, url: '/lanza', error: [object Object])
En la LTS, si lanzas sin el manejador de la caja de arriba, la promesa de navigateByUrl se rechaza con el RedirectCommand y el usuario no se mueve. Un error de este tipo es fácil de pasar por alto al copiar código de 22.2 en un proyecto con la 21.
containsTree 🆕 v22.2
🆕 Desde Angular 22.2. No existe en la LTS (Angular 21). Si usas la LTS: no hay función pública para comparar dos
UrlTreecualesquiera. Para comparar con la URL actual usaisActive(url, router, opciones), que devuelve una señal.
Ya que manejamos UrlTree, es útil saber si una URL “contiene” a otra. containsTree(contenedor, contenido, opciones?) responde a esa pregunta sin comparar cadenas:
const router = inject(Router);
containsTree(router.parseUrl('/empleados/100?orden=nombre'), router.parseUrl('/empleados')); // true
containsTree(router.parseUrl('/empleados'), router.parseUrl('/empleados/100')); // false
containsTree(router.parseUrl('/empleados/100'), router.parseUrl('/empleados'), {
paths: 'exact',
queryParams: 'ignored',
matrixParams: 'ignored',
fragment: 'ignored',
}); // false
Por defecto compara las rutas como subconjunto (paths: 'subset'), y lo mismo hace con los parámetros de consulta. La función isActive del router se apoya en ella por dentro. Te sirve, por ejemplo, para saber si la URL de volverA pertenece a una zona concreta de la aplicación.
6. Salidas con nombre
Hasta ahora, cada URL llenaba un único <router-outlet>. A veces la pantalla tiene zonas independientes: un panel de ayuda, un detalle lateral o un chat, que deben permanecer abiertos mientras navegas por lo demás, y que el usuario debería poder guardar en un marcador. Para eso sirven las salidas con nombre (named outlets).
Se añade otra salida con un nombre:
<!-- plantilla de App -->
<div class="cuerpo">
<main><router-outlet /></main>
<router-outlet name="lateral" />
</div>
La ruta indica a qué salida pertenece con outlet:
{ path: 'ayuda', outlet: 'lateral', component: AyudaLateral },
Y los enlaces indican qué poner en cada salida con un objeto outlets:
<a [routerLink]="[{ outlets: { lateral: ['ayuda'] } }]">Ayuda</a>
Con la lista de empleados abierta, ese enlace produce esta URL:
/empleados(lateral:ayuda)

Figura 4. Dos salidas activas a la vez. La principal muestra la lista, y lateral muestra la ayuda.
La parte entre paréntesis es el estado de la salida secundaria. Por eso se puede guardar en un marcador y sobrevive a una recarga:
Figura 5. Cada parte de la URL corresponde a una salida distinta.
Las dos salidas son independientes. Al pulsar un empleado en la lista, la navegación solo toca la principal:
/empleados(lateral:ayuda)
/empleados/100(lateral:ayuda) el panel sigue abierto
/empleados/100 tras cerrar el panel
Para cerrar una salida se le asigna null:
<a [routerLink]="['/', { outlets: { lateral: null } }]">Cerrar</a>
La barra inicial es importante. Este enlace está dentro de AyudaLateral, y sin / el routerLink se resuelve de forma relativa a la ruta ayuda, no a la raíz, donde vive la salida lateral. Lo hemos probado: sin la barra, el enlace no da ningún error, pero tampoco hace nada, y el panel sigue abierto.
⚠️ Importante: Una salida con nombre también puede estar dentro de un componente hijo. Entonces su ruta se declara en
children, y la URL lleva una barra antes del paréntesis:/pedidos/(detalle:pedido/5002). El ejercicio 6 construye este caso.
Las salidas con nombre tienen sus límites: las URL se vuelven largas, y un @if o un diálogo son más sencillos si el contenido no necesita su propia URL. Úsalas cuando quieras que el panel se pueda enlazar y recuperar.
7. Precarga: el código antes de que lo pidas
La carga perezosa del módulo 10 reduce el tamaño inicial, pero introduce una pausa la primera vez que entras en cada sección. La precarga ofrece lo mejor de ambos mundos: arranca con lo mínimo y, en cuanto la aplicación ya está en marcha, descarga en segundo plano las partes perezosas.
provideRouter(routes, withComponentInputBinding(), withPreloading(PreloadAllModules)),
| Estrategia | Comportamiento |
|---|---|
NoPreloading | El valor por defecto: descarga solo al navegar |
PreloadAllModules | Descarga todas las rutas perezosas tras la primera navegación |
| Una clase propia | Decides ruta por ruta |
El nombre PreloadAllModules es histórico: también precarga loadComponent, aunque no haya módulos de ningún tipo. Lo hemos comprobado en una tabla con tres rutas perezosas (informes, clientes y zona-secreta):
RouteConfigLoadStart(path: informes)
RouteConfigLoadStart(path: clientes)
RouteConfigLoadStart(path: zona-secreta)
RouteConfigLoadEnd(path: informes)
RouteConfigLoadEnd(path: zona-secreta)
RouteConfigLoadEnd(path: clientes)
Aquí se ve la advertencia de la sección 3. La ruta zona-secreta tiene un canMatch que siempre devuelve false, y aun así su fichero se ha descargado, y el guard no se ha llegado a ejecutar. Lo mismo ocurre en Angular 21.
Una estrategia propia
Descargar todo puede ser un desperdicio si la aplicación es grande. Lo razonable suele ser precargar solo lo que el usuario probablemente visitará. Una estrategia es una clase con un método preload, que recibe cada ruta perezosa y una función cargar:
// src/app/precarga.ts
import { Injectable } from '@angular/core';
import { PreloadingStrategy, Route } from '@angular/router';
import { Observable, of } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class PrecargaPorDatos implements PreloadingStrategy {
preload(ruta: Route, cargar: () => Observable<unknown>): Observable<unknown> {
return ruta.data?.['precargar'] === true ? cargar() : of(null);
}
}
{
path: 'informes',
loadComponent: () => import('./informes/informes').then((m) => m.Informes),
data: { precargar: true },
},
{
path: 'clientes',
loadComponent: () => import('./clientes/clientes').then((m) => m.Clientes),
},
provideRouter(routes, withPreloading(PrecargaPorDatos)),
Traza real, justo después de cargar la aplicación. Solo aparece informes, y siempre después del NavigationEnd inicial, de modo que la precarga no retrasa el primer dibujo:
NavigationEnd(id: 1, url: '/', urlAfterRedirects: '/')
RouteConfigLoadStart(path: informes)
RouteConfigLoadEnd(path: informes)
Como cargar() devuelve un Observable, tienes margen para retrasar la descarga o saltártela con mala conexión. Lo harás en el ejercicio 7.
8. withRouterConfig: ajustes finos
withRouterConfig agrupa las opciones generales del router:
provideRouter(
routes,
withRouterConfig({
canceledNavigationResolution: 'computed',
paramsInheritanceStrategy: 'always',
}),
),
| Opción | Valores (por defecto en negrita) | Para qué sirve |
|---|---|---|
onSameUrlNavigation | 'ignore', 'reload' | Qué hacer al navegar a la URL actual |
paramsInheritanceStrategy | 'always' (en la LTS, 'emptyOnly'), 'emptyOnly' | Si las hijas heredan parámetros y datos del padre |
canceledNavigationResolution | 'replace', 'computed' | Cómo recuperar el historial tras cancelar una navegación |
urlUpdateStrategy | 'deferred', 'eager' | Si la URL cambia al final o al principio |
defaultQueryParamsHandling | '', 'replace', 'merge', 'preserve' | Qué hacer por defecto con los parámetros de consulta al navegar |
resolveNavigationPromiseOnError | false, true | Si un error resuelve la promesa con false en vez de rechazarla |
canceledNavigationResolution: un fallo sutil del historial
Esta opción parece esotérica, pero resuelve un error real. El navegador cambia de entrada en el historial antes de avisar al router, así que cuando un canDeactivate cancela una navegación hecha con el botón Atrás, ya es tarde: el navegador ya se ha movido. El router tiene que arreglarlo, y puede hacerlo de dos formas.
Prueba real, con este historial: /, luego /empleados, y luego el formulario. Pulsamos Atrás con cambios sin guardar, cancelamos, guardamos y volvemos a pulsar Atrás:
| Tras el primer Atrás (cancelado) | Tras el segundo Atrás | |
|---|---|---|
'replace' | Formulario | / (se ha saltado /empleados) |
'computed' | Formulario | /empleados (lo esperable) |
Con 'replace', el router reescribe la entrada en la que ha caído el navegador, y /empleados desaparece del historial. Con 'computed', deshace el movimiento y vuelve a la entrada correcta. Si usas canDeactivate, activa 'computed'.
paramsInheritanceStrategy: la hija ve lo del padre 🔄 v22.0
🔄 Cambia en Angular 22.0. En la LTS (Angular 21) el valor por defecto es
'emptyOnly', y una hija con ruta propia no hereda los parámetros del padre. Si usas la LTS: añadewithRouterConfig({ paramsInheritanceStrategy: 'always' })para tener el mismo comportamiento.
Con la ruta siguiente, ¿recibe DepHija el parámetro deptId?
{
path: 'dep/:deptId',
component: DepPadre,
children: [{ path: 'lista', component: DepHija }],
},
export class DepHija {
readonly deptId = input<string>();
}
Resultados reales en /dep/10/lista, sin indicar la opción y con cada valor:
Angular 22.2, sin la opción hija recibe deptId=10
Angular 21.2, sin la opción hija recibe deptId=(nada)
'always' hija recibe deptId=10
'emptyOnly' hija recibe deptId=(nada)
'always' hace que las hijas hereden parámetros, data y datos resueltos. 'emptyOnly' es la regla antigua, en la que solo se heredaban con el padre sin componente o con una hija de ruta vacía.
Si actualizas de 21 a 22, ten cuidado: una hija puede empezar a recibir entradas que antes llegaban vacías. Para conservar la regla antigua, indica paramsInheritanceStrategy: 'emptyOnly'.
onSameUrlNavigation y runGuardsAndResolvers
Estas dos opciones engañan: onSameUrlNavigation: 'reload' no hace lo que su nombre sugiere. Con la configuración por defecto, volver a pulsar el enlace de la URL actual emite solo NavigationSkipped. Con 'reload' hay navegación, pero fíjate en lo que falta en esta traza real:
NavigationStart(id: 2, url: '/empleados/100')
[resolver] empleado 100
NavigationEnd(id: 2, url: '/empleados/100', urlAfterRedirects: '/empleados/100')
NavigationStart(id: 3, url: '/empleados/100')
NavigationEnd(id: 3, url: '/empleados/100', urlAfterRedirects: '/empleados/100')
La segunda navegación pasa por el router, pero ni el resolver ni los guards se repiten, y el componente tampoco se vuelve a crear. Se debe a una segunda regla, que decide cuándo se repiten en una ruta que sigue activa. Se configura por ruta con runGuardsAndResolvers:
| Valor | Se repiten cuando cambia… |
|---|---|
'paramsChange' (por defecto) | La ruta o sus parámetros de ruta y de matriz |
'pathParamsChange' | Solo los parámetros de ruta |
'paramsOrQueryParamsChange' | Lo anterior o los parámetros de consulta |
'pathParamsOrQueryParamsChange' | Los parámetros de ruta o los de consulta |
'always' | En cada navegación |
Una función (desde, hasta) => boolean | Lo que decidas |
Hacen falta las dos opciones para una recarga real:
withRouterConfig({ onSameUrlNavigation: 'reload' }),
{
path: 'siempre/:id',
component: FichaEmpleado,
resolve: { empleado: empleadoResolver },
runGuardsAndResolvers: 'always',
},
Con ambas, la traza muestra [resolver] empleado 100 en las dos navegaciones.
⚠️ Importante: Por la misma regla, los parámetros de consulta no vuelven a ejecutar los guards. Lo hemos comprobado: con un token caducado, pasar de
/seguroa/seguro?x=1no ejecutó de nuevo el guard que validaba el token. Si un guard depende de los parámetros de consulta, añaderunGuardsAndResolvers: 'paramsOrQueryParamsChange'.
9. Eventos del router
Cada paso de la navegación se publica en router.events, un Observable. Para verlo, nos suscribimos desde el componente raíz:
export class App {
private readonly router = inject(Router);
constructor() {
this.router.events.pipe(takeUntilDestroyed()).subscribe((evento) => {
console.log(String(evento));
});
}
}
takeUntilDestroyed() cancela la suscripción al destruir el componente (lo verás en el módulo 15). Esta es la salida real al entrar en /empleados/100 con sesión, recortando el estado de cada línea y marcando con corchetes los mensajes de nuestros guards y resolvers:
NavigationStart(id: 2, url: '/empleados/100')
RoutesRecognized(id: 2, url: '/empleados/100', urlAfterRedirects: '/empleados/100', ...)
GuardsCheckStart(id: 2, url: '/empleados/100', ...)
ChildActivationStart(path: '')
ActivationStart(path: 'empleados')
ChildActivationStart(path: 'empleados')
ActivationStart(path: ':id')
[guard] canActivateChild /empleados/100
GuardsCheckEnd(id: 2, url: '/empleados/100', ..., shouldActivate: true)
ResolveStart(id: 2, url: '/empleados/100', ...)
[resolver] empleado 100
ResolveEnd(id: 2, url: '/empleados/100', ...)
ActivationEnd(path: ':id')
ChildActivationEnd(path: 'empleados')
ActivationEnd(path: 'empleados')
ChildActivationEnd(path: '')
NavigationEnd(id: 2, url: '/empleados/100', urlAfterRedirects: '/empleados/100')
Observa cómo ActivationStart y ActivationEnd salen en orden de padre a hija y de hija a padre, respectivamente. Compara la traza con la Figura 1.
💡 Consejo: Durante el desarrollo,
withDebugTracing()imprime todos estos eventos en la consola sin escribir ni una línea más. No lo dejes en producción.
Los eventos son clases, así que se distinguen con instanceof. Un uso típico es registrar la página vista en una herramienta de analítica:
this.router.events
.pipe(
filter((evento) => evento instanceof NavigationEnd),
takeUntilDestroyed(),
)
.subscribe((evento) => registrarVisita(evento.urlAfterRedirects));
NavigationCancel trae además un campo code con el motivo:
NavigationCancellationCode | Significado |
|---|---|
Redirect | Un guard o resolver ha redirigido |
SupersededByNewNavigation | Otra navegación ha empezado antes de acabar esta |
NoDataFromResolver | Un resolver no ha emitido |
GuardRejected | Un guard ha devuelto false |
Aborted | Se ha llamado a abort() |
NavigationSkipped también lleva code, con los valores IgnoredSameUrlNavigation e IgnoredByUrlHandlingStrategy.
Una barra de carga con currentNavigation
Ya sabemos que los resolvers detienen la pantalla. Hay que avisar al usuario de que su clic ha funcionado. Antes se hacía escuchando los eventos, pero hay una forma más sencilla: router.currentNavigation() es una señal con la navegación en curso, o null si no hay ninguna.
@Component({
selector: 'app-root',
imports: [RouterOutlet, RouterLink, RouterLinkActive],
template: `
@if (navegando()) {
<div class="barra" role="progressbar" aria-label="Cargando página"></div>
}
<!-- cabecera y salidas -->
`,
})
export class App {
private readonly router = inject(Router);
protected readonly navegando = computed(() => this.router.currentNavigation() !== null);
}
.barra {
position: fixed; top: 0; left: 0; right: 0; height: 4px;
overflow: hidden; background: #c5cae9;
}
.barra::after {
content: ''; position: absolute; inset: 0; width: 40%; background: #ff7043;
animation: avanza 1s linear infinite;
}
@keyframes avanza {
from { transform: translateX(-100%); }
to { transform: translateX(260%); }
}

Figura 6. El usuario ha pulsado un empleado y el resolver espera la respuesta. La barra superior confirma que el clic ha funcionado, aunque siga viendo la lista y la URL no haya cambiado.
Ten en cuenta que la señal contiene el objeto Navigation, con initialUrl, extractedUrl, finalUrl, trigger, extras y previousNavigation. Además incluye abort(), que cancela la navegación en curso. Es útil en un botón “Cancelar” para una carga lenta:
this.router.currentNavigation()?.abort();
Lo hemos probado con una respuesta retrasada 800 ms: la navegación termina en NavigationCancel y navigateByUrl se resuelve con false.
10. Transiciones con withViewTransitions
⚠️ Importante:
withViewTransitionssigue en developer preview en Angular 22.2, igual que en la LTS. Funciona, pero su API puede cambiar. Por eso no lleva ninguna marca de versión: está disponible en ambas.
Cuando cambia de pantalla, el navegador lo hace de golpe. La View Transitions API es una función del propio navegador que toma una imagen de la página antigua y otra de la nueva, y anima el paso de una a otra. withViewTransitions hace que el router envuelva la activación de la ruta en esa API:
provideRouter(
routes,
withViewTransitions({
skipInitialTransition: true,
onViewTransitionCreated: ({ transition, from, to }) => {
console.log(`De ${from.firstChild?.routeConfig?.path} a ${to.firstChild?.routeConfig?.path}`);
transition.finished.then(() => console.log('Transición terminada'));
},
}),
),
Con esa sola línea ya hay un fundido suave. Sus opciones:
| Opción | Efecto |
|---|---|
skipInitialTransition | No anima la primera carga de la aplicación |
onViewTransitionCreated | Función, en contexto de inyección, que recibe la transición y las rutas de origen y destino |
La animación se personaliza con CSS, y como pertenece al documento debe ir en styles.css, no en un componente:
/* src/styles.css */
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 250ms;
}

Figura 7. Fotograma real a mitad de una transición (alargada a 3 segundos para poder capturarla): las pantallas Inicio y Pedidos se funden.
Si el navegador no admite document.startViewTransition, el router activa la ruta sin animación. Es una mejora progresiva: no hay que hacer nada para los navegadores antiguos.
💡 Consejo: Respeta a quien ha pedido menos movimiento en su sistema. Envuelve tus animaciones en
@media (prefers-reduced-motion: no-preference) { ... }.
11. Limpiar los inyectores de ruta: withAutoCleanupInjectors 🆕 v22.2
🆕 Desde Angular 22.2. No existe en la LTS (Angular 21). Si usas la LTS: usa
withExperimentalAutoCleanupInjectors(), disponible desde 21.1, que en nuestra prueba se comporta igual, aunque es experimental. En Angular 22 ese nombre está obsoleto.
Una ruta puede tener sus propios servicios, con la propiedad providers. Para ello, el router crea un EnvironmentInjector para esa ruta (recuerda la jerarquía del módulo 08):
{ path: 'alcance', component: Alcance, providers: [ContadorAlcance] },
Lo que quizá no esperes es que ese inyector no se destruye al salir de la ruta. Queda vivo, con sus servicios, para el resto de la sesión. Si esos servicios tienen temporizadores, conexiones o datos pesados, se acumulan. Lo comprobamos con este servicio:
@Injectable()
export class ContadorAlcance implements OnDestroy {
static creados = 0;
readonly numero = ++ContadorAlcance.creados;
valor = 0;
constructor() {
console.log(`creado #${this.numero}`);
}
ngOnDestroy() {
console.log(`destruido #${this.numero}`);
}
}
Entramos en /alcance, salimos a / y volvemos a /alcance. Sin la función:
creado #1
instancia 1, valor 1
instancia 1, valor 2 la segunda visita reutiliza el servicio
Con provideRouter(routes, withAutoCleanupInjectors()):
creado #1
instancia 1, valor 1
destruido #1 se destruye al salir de la ruta
creado #2
instancia 2, valor 1 la segunda visita empieza de cero
Esto tiene dos consecuencias:
- Se liberan recursos, y se ejecuta
ngOnDestroy, lo que antes no ocurría. - Se pierde el estado. Si contabas con que el servicio de una ruta recordara datos entre visitas, dejará de hacerlo. Ese estado debe vivir más arriba: en un servicio de la raíz o en un padre que siga activo.
La función se apoya en RouteReuseStrategy. La estrategia por defecto ya admite esta limpieza. Si escribes una estrategia propia, debe implementar shouldDestroyInjector(ruta), y retrieveStoredRouteHandles() si guarda rutas para reutilizarlas después, porque sus inyectores no deben destruirse.
12. Datos en la ruta: withRouterResources 🆕 v22.2
🆕 Desde Angular 22.2. No existe en la LTS (Angular 21). En 22.2 está en developer preview. Si usas la LTS: para datos que bloquean la navegación usa un resolver (sección 4). Para datos que no la bloquean, usa
httpResourceen el componente, con la señal de entrada del parámetro; compila en 21, aunque allí es experimental.
Los resolvers tienen un defecto: se ejecutan una vez y entregan un valor fijo. No se pueden recargar sin navegar, y siempre detienen la pantalla. Las rutas con recursos (resources) declaran en la ruta un Resource, la pieza reactiva que estudiarás a fondo en el módulo 13. De momento, basta con saber que es un objeto que carga datos y expone su estado con señales: value(), isLoading(), error(), hasValue() y reload().
⚠️ Importante: En Angular 22.2 esto sigue en developer preview, junto con
nonBlocking,ResourceContextyResourceResult. Puede cambiar en versiones futuras. Para una aplicación en producción, los resolvers siguen siendo la opción estable.
Se activa con withRouterResources() y se declara con la propiedad resources de la ruta. Esa función recibe un contexto con señales: params, queryParams, fragment y data.
provideRouter(routes, withComponentInputBinding(), withRouterResources()),
{
path: 'fichas/:id',
component: RecursoFicha,
resources: (ctx) => ({
empleado: httpResource<Empleado>(() => `${API}/empleados/${ctx.params()['id']}`),
}),
},
Bloqueante, por defecto
Sin más indicaciones, el recurso bloquea la navegación, como un resolver. El componente recibe directamente el valor, no el Resource:
@Component({
selector: 'app-recurso-ficha',
template: `
<h2>{{ empleado().nombre }} {{ empleado().apellidos }}</h2>
<p>{{ empleado().oficio }}</p>
<button type="button" (click)="recargar()">Recargar</button>
<p>{{ resultado() }}</p>
`,
})
export class RecursoFicha {
readonly empleado = input.required<Empleado>();
private readonly recurso = inject(ActivatedRoute).resources?.['empleado'];
protected readonly resultado = signal('');
protected recargar() {
const recargado = this.recurso?.reload();
this.resultado.set(`reload() devolvió ${recargado}`);
}
}
La gran ventaja frente a un resolver es esa línea de reload(): refresca los datos sin navegar, sin repetir guards. Para llegar a ella hay que leer el recurso desde ActivatedRoute.resources, porque la entrada solo trae el valor.
Con la API retrasada 500 ms, comprobamos varias cosas:
Entrada en /fichas/100: tarda unos 700 ms en resolverse navigateByUrl
A mitad de ir a /fichas/110: sigue "Ana García López" y router.url es /fichas/100
Al terminar: "Carmen Ruiz Ortega"
reload() con la página en reposo: true
reload() durante otra navegación: false
Se llama estado congelado. Mientras hay una navegación en curso, el router mantiene los recursos en su último valor, de modo que la pantalla no muestra un estado de carga intermedio. Por eso reload() devuelve false en ese momento. Los recursos que expone ActivatedRoute son de solo lectura.
Sin bloquear: nonBlocking
Si los datos tardan mucho, envuelve el recurso en nonBlocking. La navegación termina al momento y el componente recibe el Resource completo, con lo que él mismo decide qué mostrar:
{
path: 'fichas-nb/:id',
component: RecursoLento,
resources: (ctx) => ({
empleado: nonBlocking(httpResource<Empleado>(() => `${API}/empleados/${ctx.params()['id']}`)),
}),
},
@Component({
selector: 'app-recurso-lento',
template: `
@if (empleado().isLoading()) {
<p>Cargando ficha...</p>
} @else if (empleado().error()) {
<p>No se ha podido cargar la ficha.</p>
} @else if (empleado().hasValue()) {
<h2>{{ empleado().value().nombre }}</h2>
}
`,
})
export class RecursoLento {
readonly empleado = input.required<Resource<Empleado>>();
}
Observa el tipo de la entrada: Empleado en el caso bloqueante, Resource<Empleado> aquí. Las dos rutas no son intercambiables sin tocar el componente. Comprobado: justo después de navegar se ve “Cargando ficha…”, después “Ana”, y con un empleado inexistente, “No se ha podido cargar la ficha.”.
Errores y redirecciones
| Tipo de recurso | Si falla | Ejemplo real con /fichas/1 |
|---|---|---|
| Bloqueante | NavigationError; la navegación se rechaza | Sigues en la página anterior |
nonBlocking | La navegación termina y el error está en error() | Se muestra el mensaje de error |
Como en un resolver, el loader de un resource bloqueante puede lanzar un RedirectCommand:
{
path: 'fichas-rc/:id',
component: RecursoFicha,
resources: (ctx) => {
const http = inject(HttpClient);
const router = inject(Router);
return {
empleado: resource({
params: () => ctx.params()['id'],
loader: async ({ params: id }) => {
try {
return await firstValueFrom(http.get<Empleado>(`${API}/empleados/${id}`));
} catch (error) {
if (error instanceof HttpErrorResponse && error.status === 404) {
throw new RedirectCommand(router.parseUrl('/no-encontrado'));
}
throw error;
}
},
}),
};
},
},
La función resources se ejecuta en un contexto de inyección, y por eso puede llamar a inject(), igual que un guard. Con /fichas-rc/1, la traza muestra NavigationCancel y luego la navegación a /no-encontrado.
Tres formas de cargar los datos
Figura 8. Lo que ve el usuario en cada opción mientras llegan los datos.
| Resolver | resources bloqueante | nonBlocking | httpResource en el componente | |
|---|---|---|---|---|
| Estado de la API | Estable | Developer preview | Developer preview | Estable en 22, experimental en 21 |
| ¿Espera a los datos? | Sí | Sí | No | No |
| Entrada del componente | T | T | Resource<T> | Solo el id |
| Recargar sin navegar | No | Sí | Sí | Sí |
| Estado de carga en pantalla | No hace falta | No hace falta | Sí | Sí |
Para terminar, esta es la alternativa para la LTS que recomienda la caja del principio de la sección. La hemos compilado y ejecutado en Angular 21.2:
@Component({
selector: 'app-ficha-senal',
template: `
@if (empleado.isLoading()) {
<p>Cargando ficha...</p>
} @else if (empleado.error()) {
<p>No se ha podido cargar la ficha.</p>
} @else if (empleado.hasValue()) {
<h2>{{ empleado.value().nombre }}</h2>
}
`,
})
export class FichaSenal {
readonly id = input.required<string>();
protected readonly empleado = httpResource<Empleado>(() => `${API}/empleados/${this.id()}`);
}
13. Errores frecuentes y cómo depurarlos
| Síntoma | Causa habitual | Solución |
|---|---|---|
NG0203 en un guard o resolver | inject() después de un await o dentro de un setTimeout | Inyecta al principio de la función |
NG0203 al definir las rutas | inject() en la función que fabrica el guard, no en la que devuelve | Muévelo a la función interna |
| Clic en un enlace sin efecto visible | Un guard devuelve false o el resolver tarda | Mira NavigationCancel y su code; añade una barra de carga |
| Pantalla sin cambios y error en consola | Resolver sin capturar el error | catchError o withNavigationErrorHandler |
| Navegación cancelada sin ningún mensaje | Resolver que no emite | Asegúrate de que el Observable emite un valor |
| Bucle de redirecciones | El guard protege también la ruta a la que redirige | Deja /login fuera de la zona protegida |
| Un guard lee datos que otro no ha dejado todavía | Los guards de un array empiezan a la vez | Reúnelos en una función |
El resolver no se repite con 'reload' | Falta runGuardsAndResolvers | Añade runGuardsAndResolvers: 'always' |
Un guard no reacciona a ?parametro= | Por defecto no se repite con parámetros de consulta | runGuardsAndResolvers: 'paramsOrQueryParamsChange' |
| El botón Atrás se salta una página | Navegación cancelada con 'replace' | canceledNavigationResolution: 'computed' |
throw new RedirectCommand da NavigationError | Estás en Angular 21 | Usa return, o el manejador de la sección 5 |
| El enlace para cerrar la salida no hace nada | routerLink relativo a la ruta de la propia salida | Empieza el array con '/' |
input.required falla con NG0950 | Falta withComponentInputBinding() o el nombre no coincide con la clave de resolve | Revisa provideRouter y las claves |
| Los servicios de una ruta conservan datos | El inyector de ruta sigue vivo | withAutoCleanupInjectors() |
Para depurar, sigue este orden:
- Activa
withDebugTracing()o registrarouter.events, y localiza en qué fase se detiene. - Si ves
NavigationCancel, mira sucode. - Si ves
NavigationError, miraerror.error. - Si no aparece
NavigationStart, el problema no está en el router, sino en el enlace o en la llamada.
14. Ejercicios
Ejercicio 1: Guard que pregunta al servidor
sesionGuard se fía de lo que hay en el navegador, pero el token puede haber caducado. Escribe tokenValidoGuard, que consulte GET /api/auth/yo con la cabecera Authorization antes de dejar pasar. Si el servidor responde con error, debe cerrar la sesión y enviar al usuario a /login con volverA.
Ver solución
Añadimos un método a Sesion. Atrapa cualquier error y lo convierte en false:
// dentro de la clase Sesion
comprobar(): Observable<boolean> {
const token = this.datos()?.token;
if (!token) {
return of(false);
}
return this.http
.get(`${API}/auth/yo`, { headers: { Authorization: `Bearer ${token}` } })
.pipe(
map(() => true),
catchError(() => {
this.cerrar();
return of(false);
}),
);
}
export const tokenValidoGuard: CanActivateFn = (_ruta, estado) => {
const sesion = inject(Sesion);
const router = inject(Router);
return sesion.comprobar().pipe(
map((valido) => valido || router.createUrlTree(['/login'], { queryParams: { volverA: estado.url } })),
);
};
El guard devuelve un Observable, y el router espera a su primer valor. Hemos inyectado todo al principio, de modo que el map no necesita contexto.
En la prueba, con un token correcto se entra en /seguro. Al estropearlo y volver a entrar desde otra página, la API responde 401, la sesión se cierra y la aplicación acaba en /login?volverA=%2Fseguro.
Ejercicio 2: Diálogo propio para los cambios sin guardar
El confirm del navegador es feo y bloquea toda la página. Sustitúyelo por un diálogo propio dentro de la aplicación. El guard debe esperar a que el usuario elija.
Ver solución
Un guard puede devolver una Promise. Basta con que un servicio cree una que se resuelva al pulsar un botón:
@Injectable({ providedIn: 'root' })
export class Dialogo {
readonly pregunta = signal<string | null>(null);
private responder: ((valor: boolean) => void) | null = null;
confirmar(pregunta: string): Promise<boolean> {
this.pregunta.set(pregunta);
return new Promise((resolver) => (this.responder = resolver));
}
responderCon(valor: boolean): void {
this.pregunta.set(null);
this.responder?.(valor);
this.responder = null;
}
}
@Component({
selector: 'app-dialogo',
template: `
@if (dialogo.pregunta(); as pregunta) {
<div class="velo">
<div class="caja" role="alertdialog" aria-modal="true">
<p>{{ pregunta }}</p>
<button type="button" (click)="dialogo.responderCon(false)">Seguir editando</button>
<button type="button" class="peligro" (click)="dialogo.responderCon(true)">Salir sin guardar</button>
</div>
</div>
}
`,
styles: `
.velo { position: fixed; inset: 0; background: rgb(0 0 0 / 40%); display: grid; place-items: center; }
.caja { background: #fff; padding: 1.25rem; border-radius: 8px; max-width: 24rem; }
.peligro { background: #c62828; color: #fff; border: 0; padding: .4rem .8rem; border-radius: 4px; }
`,
})
export class DialogoConfirmacion {
protected readonly dialogo = inject(Dialogo);
}
Coloca <app-dialogo /> en la plantilla de App, y cambia el guard:
export const sinCambiosDialogoGuard: CanDeactivateFn<ConCambios> = (componente) => {
if (!componente.hayCambiosSinGuardar()) {
return true;
}
return inject(Dialogo).confirmar('Hay cambios sin guardar. ¿Quieres salir igualmente?');
};

Figura 9. La navegación queda en espera mientras decides. Por eso se ve también la barra de carga de la sección 9.
“Seguir editando” deja la URL igual, y “Salir sin guardar” continúa hacia /empleados. Observa la barra de la parte superior de la captura: la navegación está en curso, a la espera de la promesa.
Ejercicio 3: Bandera de funcionalidad con canMatch
Quieres probar un listado de productos nuevo sin retirar el antiguo. Lee una bandera de localStorage (listadoNuevo) y sirve en la misma URL /productos un componente u otro.
Ver solución
@Injectable({ providedIn: 'root' })
export class Funciones {
readonly listadoNuevo = signal(localStorage.getItem('listadoNuevo') === 'si');
}
export const listadoNuevoMatch: CanMatchFn = () => inject(Funciones).listadoNuevo();
{ path: 'productos', component: ProductosNuevo, canMatch: [listadoNuevoMatch] },
{ path: 'productos', component: ProductosClasico },
Con listadoNuevo = 'si' se muestra Productos (listado nuevo); con cualquier otro valor, Productos (listado clásico). Al llegar a la segunda ruta sin canMatch siempre hay coincidencia, así que el orden es importante. Si la nueva pantalla usara loadComponent, quien no tuviera la bandera no descargaría su código.
Ejercicio 4: Resolver que junta dos peticiones
Crea /departamentos/:id/plantilla, que muestre el nombre del departamento, su localidad y cuántos empleados tiene. Los datos deben estar listos antes de entrar, y un departamento inexistente debe mostrar “No encontrado” sin cambiar la URL. Hazlo con async/await, sin RedirectCommand lanzado, para que funcione en cualquier versión.
Ver solución
export interface Plantilla {
departamento: Departamento;
empleados: Empleado[];
}
export const plantillaResolver: ResolveFn<Plantilla> = async (ruta) => {
const http = inject(HttpClient);
const router = inject(Router);
const id = ruta.paramMap.get('id');
try {
const [departamento, empleados] = await Promise.all([
firstValueFrom(http.get<Departamento>(`${API}/departamentos/${id}`)),
firstValueFrom(http.get<Pagina<Empleado>>(`${API}/departamentos/${id}/empleados`)),
]);
return { departamento, empleados: empleados.contenido };
} catch (error) {
if (error instanceof HttpErrorResponse && error.status === 404) {
return new RedirectCommand(router.parseUrl('/no-encontrado'), { skipLocationChange: true });
}
throw error;
}
};
@Component({
selector: 'app-plantilla',
template: `
<h2>{{ plantilla().departamento.nombre }}</h2>
<p>{{ plantilla().empleados.length }} personas en {{ plantilla().departamento.localidad }}</p>
`,
})
export class PlantillaDepartamento {
readonly plantilla = input.required<Plantilla>();
}
Promise.all lanza las dos peticiones a la vez, y inject() se llama antes del primer await. Comprobado: /departamentos/20/plantilla muestra “VENTAS, 6 personas en Madrid”, y /departamentos/999/plantilla muestra “No encontrado” con la barra de direcciones sin cambios.
Ejercicio 5: Miga de pan a partir de las rutas
Muestra una miga de pan (breadcrumb) sobre el contenido, usando una etiqueta en data de cada ruta. El último elemento no debe ser un enlace.
Ver solución
{
path: 'empleados',
canActivate: [sesionGuard],
data: { miga: 'Empleados' },
children: [
{ path: '', component: ListaEmpleados },
{ path: ':id', component: FichaEmpleado, resolve: { empleado: empleadoResolver }, data: { miga: 'Ficha' } },
],
},
export interface Miga {
etiqueta: string;
url: string;
}
@Component({
selector: 'app-migas',
imports: [RouterLink],
template: `
<nav aria-label="Miga de pan">
@for (miga of migas(); track miga.url; let ultima = $last) {
@if (ultima) {
<span aria-current="page">{{ miga.etiqueta }}</span>
} @else {
<a [routerLink]="miga.url">{{ miga.etiqueta }}</a> /
}
}
</nav>
`,
})
export class Migas {
private readonly router = inject(Router);
protected readonly migas = signal<Miga[]>([]);
constructor() {
this.router.events
.pipe(
filter((evento) => evento instanceof NavigationEnd),
takeUntilDestroyed(),
)
.subscribe(() => this.migas.set(this.construir(this.router.routerState.snapshot.root)));
}
private construir(raiz: ActivatedRouteSnapshot): Miga[] {
const migas: Miga[] = [];
let ruta: ActivatedRouteSnapshot | null = raiz;
while (ruta) {
const etiqueta = ruta.data['miga'] as string | undefined;
const segmentos = ruta.pathFromRoot.flatMap((r) => r.url.map((s) => s.path));
const url = '/' + segmentos.join('/');
if (etiqueta && migas.at(-1)?.url !== url) {
migas.push({ etiqueta, url });
}
ruta = ruta.firstChild;
}
return migas;
}
}

Figura 10. Miga de pan real en /empleados/100.
La comprobación migas.at(-1)?.url !== url existe por la herencia de data de la sección 8: la hija con path: '' hereda miga: 'Empleados' con cualquier estrategia, y tiene la misma URL que su padre, de modo que sin esa condición la etiqueta saldría repetida. Recorremos firstChild porque solo nos interesa la salida principal.
Ejercicio 6: Detalle de pedido en una salida hija
En /pedidos, pulsar un pedido debe mostrar su detalle debajo de la lista, sin abandonar la lista, con una URL que se pueda guardar. Usa una salida con nombre dentro de ListaPedidos, y un botón para cerrarla.
Ver solución
{
path: 'pedidos',
component: ListaPedidos,
children: [
{ path: 'pedido/:id', outlet: 'detalle', component: DetallePedido, resolve: { pedido: pedidoResolver } },
],
},
export const pedidoResolver: ResolveFn<Pedido> = (ruta) =>
inject(HttpClient).get<Pedido>(`${API}/pedidos/${ruta.paramMap.get('id')}`);
@Component({
selector: 'app-lista-pedidos',
imports: [RouterLink, RouterOutlet],
template: `
<h2>Pedidos</h2>
<ul>
@for (p of pedidos(); track p.pedidoId) {
<li>
<a [routerLink]="[{ outlets: { detalle: ['pedido', p.pedidoId] } }]">{{ p.pedidoId }}</a>
{{ p.cliente }}
</li>
}
</ul>
<router-outlet name="detalle" />
`,
})
export class ListaPedidos {
private readonly datos = toSignal(inject(HttpClient).get<Pagina<Pedido>>(`${API}/pedidos?tamano=4`));
protected readonly pedidos = computed(() => this.datos()?.contenido ?? []);
}
@Component({
selector: 'app-detalle-pedido',
template: `
<h3>Pedido {{ pedido().pedidoId }}</h3>
<p>{{ pedido().cliente }}</p>
<p>Estado: {{ pedido().estado }}</p>
<button type="button" (click)="cerrar()">Cerrar</button>
`,
})
export class DetallePedido {
private readonly router = inject(Router);
private readonly ruta = inject(ActivatedRoute);
readonly pedido = input.required<Pedido>();
protected cerrar() {
this.router.navigate([{ outlets: { detalle: null } }], { relativeTo: this.ruta.parent });
}
}

Figura 11. El detalle aparece debajo de la lista, en la salida detalle de ListaPedidos.
Al pulsar el pedido 5002, la URL pasa a ser /pedidos/(detalle:pedido/5002). Fíjate en la barra antes del paréntesis: la salida pertenece a un hijo de pedidos, no a la raíz. Al cerrar, vuelve a /pedidos.
relativeTo: this.ruta.parent es necesario porque la ruta actual es la propia pedido/:id, mientras que la salida detalle pertenece a su padre. toSignal convierte el Observable en señal, como verás en el módulo 15.
Ejercicio 7: Precarga prudente
Mejora PrecargaPorDatos: debe esperar 2 segundos antes de descargar, para no competir con el arranque, y no debe precargar nada si el usuario tiene activado el ahorro de datos o una conexión 2G.
Ver solución
interface ConexionRed {
saveData?: boolean;
effectiveType?: string;
}
@Injectable({ providedIn: 'root' })
export class PrecargaPrudente implements PreloadingStrategy {
preload(ruta: Route, cargar: () => Observable<unknown>): Observable<unknown> {
const conexion = (navigator as Navigator & { connection?: ConexionRed }).connection;
const redLimitada = conexion?.saveData === true || conexion?.effectiveType === '2g';
if (ruta.data?.['precargar'] !== true || redLimitada) {
return of(null);
}
return timer(2000).pipe(switchMap(() => cargar()));
}
}
navigator.connection no existe en todos los navegadores ni aparece en los tipos de TypeScript, y por eso lo declaramos a mano y lo leemos con ?.. Si no existe, se precarga. timer(2000) emite una vez a los 2 segundos, y switchMap pasa a la descarga. Estos operadores aparecen en el módulo 14.
En el laboratorio, con la página en reposo durante 2,6 segundos, aparecen RouteConfigLoadStart(path: informes) y su RouteConfigLoadEnd, y nada más.
Ejercicio 8: Auditoría de navegaciones canceladas
Crea un servicio Auditoria que guarde la URL y el motivo, en español, de cada navegación cancelada, y cuente los errores. Debe exponer señales.
Ver solución
export interface Cancelacion {
url: string;
motivo: string;
}
const MOTIVOS: Record<NavigationCancellationCode, string> = {
[NavigationCancellationCode.Redirect]: 'Redirigida por un guard o un resolver',
[NavigationCancellationCode.SupersededByNewNavigation]: 'Sustituida por otra navegación',
[NavigationCancellationCode.NoDataFromResolver]: 'Un resolver no emitió datos',
[NavigationCancellationCode.GuardRejected]: 'Rechazada por un guard',
[NavigationCancellationCode.Aborted]: 'Abortada desde el código',
};
@Injectable({ providedIn: 'root' })
export class Auditoria {
private readonly lista = signal<Cancelacion[]>([]);
private readonly errores = signal(0);
readonly cancelaciones = this.lista.asReadonly();
readonly resumen = computed(() => `${this.lista().length} canceladas, ${this.errores()} con error`);
constructor() {
inject(Router).events.pipe(takeUntilDestroyed(inject(DestroyRef))).subscribe((evento) => {
if (evento instanceof NavigationCancel) {
this.lista.update((l) => [
...l,
{ url: evento.url, motivo: MOTIVOS[evento.code ?? NavigationCancellationCode.Redirect] },
]);
} else if (evento instanceof NavigationError) {
this.errores.update((n) => n + 1);
}
});
}
}
Un servicio se crea la primera vez que alguien lo pide, de modo que hay que inyectarlo en App para que escuche desde el principio. Usar Record<NavigationCancellationCode, string> tiene una ventaja: si una versión futura añade un código, el compilador te avisará de que falta su texto.
Forzamos una navegación de cada tipo y obtuvimos esto:
["4 canceladas, 1 con error", [
{ "url": "/nominas", "motivo": "Redirigida por un guard o un resolver" },
{ "url": "/orden", "motivo": "Rechazada por un guard" },
{ "url": "/vacio", "motivo": "Un resolver no emitió datos" },
{ "url": "/orden2", "motivo": "Sustituida por otra navegación" }
]]
Ejercicio 9 (propuesto): De resolver a recursos
Requiere Angular 22.2. Reescribe la pantalla del ejercicio 4 con withRouterResources, con dos recursos en la misma ruta: departamento y empleados. Añade un botón “Actualizar” que recargue solo los empleados, sin navegar. ¿Qué entrada recibe el componente si haces que los empleados sean nonBlocking?
Ejercicio 10 (propuesto): Transición de tarjeta a ficha
Añade withViewTransitions y da a la cabecera de la ficha de empleado y al nombre en la lista el mismo view-transition-name para que el nombre se desplace de un lugar a otro. Pista: asigna el nombre con un binding de estilo, con un valor único por empleado ('empleado-' + e.empId), porque dos elementos visibles no pueden compartirlo. Respeta prefers-reduced-motion.
Resumen
| Concepto | API | Idea clave |
|---|---|---|
| Entrar | canActivate, CanActivateFn | Cancela o redirige; se ejecuta en contexto de inyección |
| Moverse entre hijas | canActivateChild | Se comprueba en cada cambio de hija |
| Salir | canDeactivate, CanDeactivateFn<T> | Recibe el componente; no cubre cerrar la pestaña |
| Elegir ruta | canMatch, CanMatchFn | false prueba la siguiente; varias pantallas en una URL |
| Resultado de un guard | boolean \| UrlTree \| RedirectCommand | También en Promise u Observable |
| Varios guards | canActivate: [a, b] | Empiezan a la vez; decide el primero que no da true |
| Datos previos | resolve, ResolveFn<T> | La pantalla espera; llegan como entrada |
| Errores de navegación | withNavigationErrorHandler | Punto único; puede devolver RedirectCommand |
| Redirigir con opciones | RedirectCommand | skipLocationChange, replaceUrl, state |
| Lanzar la redirección 🆕 v22.2 | throw new RedirectCommand(...) | En la LTS da NavigationError |
| Comparar URL 🆕 v22.2 | containsTree | Compara dos UrlTree; en la LTS, isActive |
| Zonas independientes | <router-outlet name>, outlet, { outlets } | /a(lateral:b); se cierra con null |
| Precarga | withPreloading, PreloadAllModules, PreloadingStrategy | Tras la primera navegación; ignora canMatch |
| Configuración | withRouterConfig | Usa 'computed' si tienes canDeactivate |
| Herencia de parámetros 🔄 v22.0 | paramsInheritanceStrategy | 'always' por defecto; en la LTS, 'emptyOnly' |
| Repetir guards y resolvers | runGuardsAndResolvers | Por defecto, no con parámetros de consulta |
| Eventos | router.events, NavigationCancel.code | withDebugTracing en desarrollo |
| Navegación en curso | router.currentNavigation(), abort() | Señal para la barra de carga |
| Animaciones (developer preview) | withViewTransitions | Usa la View Transitions API; CSS global |
| Limpieza 🆕 v22.2 | withAutoCleanupInjectors | Destruye inyectores de ruta; en la LTS, nombre experimental |
| Recursos en la ruta 🆕 v22.2 (developer preview) | withRouterResources, resources, nonBlocking | Bloqueante da T; nonBlocking da Resource<T> |