Módulo 10: Router
Objetivos del módulo
- Entender cómo una aplicación de una sola página simula varias páginas, y qué papel juegan la History API y el servidor
- Configurar el enrutador con
provideRoutery sus funcioneswith... - Definir rutas, razonar sobre el orden de coincidencia y usar
RouterOutlet - Navegar con
routerLinky desde código conRouter, con rutas absolutas y relativas, parámetros de consulta y fragmentos - Marcar el enlace activo con
RouterLinkActive, sin olvidar la accesibilidad, y consultar el estado conisActive - Recibir parámetros en
input()conwithComponentInputBindingy conocer su trampa con los valores por defecto - Leer esos mismos datos con
ActivatedRoutey explicar por quésnapshotfalla cuando Angular reutiliza un componente - Guardar el estado de una pantalla en la URL: filtros, orden y paginación
- Construir pantallas anidadas con rutas hijas y entender la herencia de parámetros, que cambia en Angular 22
- Reducir el código inicial con carga perezosa:
loadComponentyloadChildren - Poner títulos estáticos, dinámicos y con formato común mediante
TitleStrategy - Crear redirecciones fijas y calculadas, y una página 404 con el comodín
** - Diagnosticar los fallos más habituales del enrutador
1. Qué hace un enrutador
Una aplicación Angular es una SPA (Single Page Application): el servidor entrega un único index.html y, a partir de ahí, JavaScript dibuja todas las pantallas. Sin nada más, EMPRESA tendría una sola dirección. Eso rompe cosas que el usuario da por hechas:
- No podría guardar en marcadores la ficha de un empleado.
- No podría enviar a un compañero un enlace a un listado ya filtrado.
- Los botones Atrás y Adelante del navegador lo sacarían de la aplicación.
- Al recargar volvería siempre a la pantalla inicial.
El enrutador (router) resuelve esto. Mantiene la URL y la pantalla sincronizadas en los dos sentidos: si cambia la URL, cambia lo que se ve; si navegas dentro de la aplicación, cambia la URL. Y lo hace sin pedir ninguna página nueva al servidor.
La pieza que lo permite es la History API del navegador. history.pushState cambia la dirección y añade una entrada al historial sin recargar. Cuando el usuario pulsa Atrás, el navegador lanza el evento popstate y el enrutador reacciona dibujando lo que corresponde.
Figura 1. Una navegación del enrutador. Atrás y Adelante entran por el mismo camino, a partir del paso 2.
📘 Concepto: Una ruta es una regla que asocia un patrón de URL con lo que hay que mostrar. El enrutador recorre la tabla de rutas, se queda con la primera que encaja y coloca su componente en un
<router-outlet>.
Lo que hemos comprobado en el navegador
Para no quedarnos en la teoría, hemos marcado la página con una variable en window y hemos navegado por la aplicación de este módulo con un navegador real:
| Acción | URL resultante | ¿Sobrevive la variable? |
|---|---|---|
| Clic en enlaces del menú y de la lista | /empleados/110 | Sí: la página no se ha recargado |
| Atrás dos veces | /empleados?q=r&pagina=2 | Sí, y el buscador recupera su texto |
Recargar con F5 en /departamentos/30/empleados | La misma | No: la aplicación arranca de cero, pero abre la pantalla correcta |
La última fila enseña lo importante: tras una recarga se pierde lo que estaba en memoria, pero la pantalla se reconstruye sola a partir de la URL. Una URL bien diseñada es una forma de estado que aguanta recargas y se puede compartir.
Una advertencia sobre el servidor
Si abres /departamentos/30/empleados directamente, la petición llega al servidor, y en él no existe ese fichero. El servidor debe contestar con index.html para cualquier ruta que no sea un fichero real, y entonces Angular arranca, lee la URL y dibuja la pantalla. ng serve ya lo hace por ti, por eso en desarrollo funciona siempre. En producción hay que configurarlo: si no, la aplicación funciona mientras navegas y da un 404 al recargar. Lo verás con Nginx y GitHub Pages en el módulo 27.
2. Puesta en marcha
ng new deja el enrutador preparado. Hay cuatro piezas, y conviene saber qué hace cada una.
1. La etiqueta <base> de src/index.html indica desde qué ruta se resuelven las URL relativas:
<base href="/">
Si publicas la aplicación en una subcarpeta, como https://midominio.es/empresa/, debe valer /empresa/. Si no coincide, los ficheros .js dan 404 al abrir una ruta profunda.
2. La tabla de rutas es un array de objetos Route. Routes es simplemente Route[]:
// src/app/app.routes.ts (primera versión)
import { Routes } from '@angular/router';
import { Inicio } from './paginas/inicio';
import { ListaEmpleados } from './empleados/lista-empleados';
export const routes: Routes = [
{ path: '', component: Inicio },
{ path: 'empleados', component: ListaEmpleados },
];
3. provideRouter registra los servicios del enrutador al arrancar:
// src/app/app.config.ts
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter, withComponentInputBinding, withInMemoryScrolling } from '@angular/router';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(
routes,
withComponentInputBinding(),
withInMemoryScrolling({ scrollPositionRestoration: 'enabled', anchorScrolling: 'enabled' }),
),
],
};
Los argumentos que siguen a routes son características (features): funciones with... que activan lo que necesites. Como son funciones independientes, las que no uses no acaban en el paquete final. Estas son las que aparecen en este módulo:
| Función | Para qué sirve | Sección |
|---|---|---|
withComponentInputBinding() | Pasa parámetros de la ruta a los input() del componente | 6 |
withInMemoryScrolling(opciones) | Restaura el scroll al navegar y salta a los fragmentos #seccion | 12 |
withHashLocation() | Usa URL con #, para servidores que no se pueden configurar | 12 |
withDebugTracing() | Escribe en la consola cada paso de una navegación | 13 |
Otras, como withRouterConfig, withPreloading o withViewTransitions, se ven en el módulo 11.
4. RouterOutlet marca el hueco donde aparecerá el componente de la ruta. Este es el componente raíz completo de EMPRESA, que iremos explicando por partes:
// src/app/app.ts
import { Component, computed, inject } from '@angular/core';
import { Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
@Component({
selector: 'app-root',
imports: [RouterOutlet, RouterLink, RouterLinkActive],
template: `
<header class="cabecera">
<strong>EMPRESA</strong>
<nav aria-label="Principal">
<a
routerLink="/"
routerLinkActive="activo"
[routerLinkActiveOptions]="{ exact: true }"
ariaCurrentWhenActive="page"
>Inicio</a>
<a routerLink="/empleados" routerLinkActive="activo" ariaCurrentWhenActive="page">Empleados</a>
<a routerLink="/departamentos" routerLinkActive="activo" ariaCurrentWhenActive="page">Departamentos</a>
</nav>
@if (navegando()) {
<span class="navegando">Cargando...</span>
}
</header>
<main>
<router-outlet />
</main>
`,
styles: `
:host { display: block; font-family: 'Segoe UI', Arial, sans-serif; color: #37474f; }
.cabecera { display: flex; align-items: center; gap: 2rem; padding: .75rem 1.25rem; background: #1a237e; color: #fff; }
nav { display: flex; gap: .25rem; }
nav a { color: #c5cae9; text-decoration: none; padding: .35rem .75rem; border-radius: .35rem; }
nav a.activo { background: #3f51b5; color: #fff; font-weight: 600; }
.navegando { margin-left: auto; color: #ffab91; }
main { padding: 1rem 1.25rem; }
`,
})
export class App {
private readonly router = inject(Router);
// currentNavigation es una señal: vale null cuando no hay navegación en curso
protected readonly navegando = computed(() => this.router.currentNavigation() !== null);
}
Fíjate en que la cabecera queda fuera del <router-outlet>. Todo lo que esté fuera se mantiene entre navegaciones, y solo cambia lo de dentro. Por eso los elementos comunes van en App y no se repiten en cada pantalla.
El componente de la ruta no se dibuja dentro de <router-outlet>, sino justo después, como elemento hermano. Si inspeccionas el DOM verás:
<main>
<router-outlet></router-outlet>
<app-lista-empleados>...</app-lista-empleados>
</main>
Esto importa cuando maquetas: un selector CSS como main > app-lista-empleados funciona, pero no puedes dar estilo a la pantalla a través de router-outlet.
📘 Concepto:
router.currentNavigationes una señal con la navegación en curso, onullsi no hay ninguna. Sustituye agetCurrentNavigation(), obsoleto desde la 20.2. Con ella, el indicador «Cargando…» de arriba no necesita suscripciones. Tienes las señales en el módulo 05.
⚠️ Importante: En código antiguo verás
provideRoutes(rutas). Estaba obsoleta, se eliminó en Angular 22 y en la 21 solo sobrevive por compatibilidad. UsaprovideRouter.
Los datos de este módulo
Para centrarnos en el enrutador, los datos viven en un fichero. Son los mismos de la API de EMPRESA, con menos campos. Las llamadas HTTP llegarán en el módulo 12, y la carga de datos ligada a parámetros en el módulo 13.
// src/app/datos.ts
export interface Departamento {
deptId: number;
nombre: string;
localidad: string;
}
export interface Empleado {
empId: number;
nombre: string;
apellidos: string;
oficio: string;
salario: number;
deptId: number | null;
}
export const DEPARTAMENTOS: Departamento[] = [
{ deptId: 10, nombre: 'DIRECCIÓN', localidad: 'Madrid' },
{ deptId: 20, nombre: 'VENTAS', localidad: 'Madrid' },
{ deptId: 30, nombre: 'INFORMÁTICA', localidad: 'Valencia' },
{ deptId: 40, nombre: 'CONTABILIDAD', localidad: 'Sevilla' },
{ deptId: 50, nombre: 'MARKETING', localidad: 'Barcelona' },
{ deptId: 60, nombre: 'LOGÍSTICA', localidad: 'Zaragoza' },
{ deptId: 70, nombre: 'INVESTIGACIÓN', localidad: 'Bilbao' },
];
export const EMPLEADOS: Empleado[] = [
{ empId: 100, nombre: 'Ana', apellidos: 'García López', oficio: 'DIRECTOR GENERAL', salario: 6500, deptId: 10 },
{ empId: 101, nombre: 'Luis', apellidos: 'Martínez Ruiz', oficio: 'DIRECTOR', salario: 4800, deptId: 20 },
{ empId: 102, nombre: 'Marta', apellidos: 'Sánchez Gil', oficio: 'DIRECTOR', salario: 5000, deptId: 30 },
{ empId: 103, nombre: 'Pedro', apellidos: 'Gómez Díaz', oficio: 'DIRECTOR', salario: 4200, deptId: 40 },
{ empId: 104, nombre: 'Laura', apellidos: 'Fernández Moreno', oficio: 'DIRECTOR', salario: 4300, deptId: 50 },
{ empId: 105, nombre: 'Javier', apellidos: 'López Navarro', oficio: 'DIRECTOR', salario: 4000, deptId: 60 },
{ empId: 110, nombre: 'Carmen', apellidos: 'Ruiz Ortega', oficio: 'VENDEDOR', salario: 2200, deptId: 20 },
{ empId: 111, nombre: 'David', apellidos: 'Torres Romero', oficio: 'VENDEDOR', salario: 2100, deptId: 20 },
{ empId: 112, nombre: 'Elena', apellidos: 'Navarro Castro', oficio: 'VENDEDOR', salario: 2300, deptId: 20 },
{ empId: 113, nombre: 'Sergio', apellidos: 'Jiménez Vidal', oficio: 'VENDEDOR', salario: 1900, deptId: 20 },
{ empId: 114, nombre: 'Lucía', apellidos: 'Romero Serrano', oficio: 'ADMINISTRATIVO', salario: 1700, deptId: 20 },
{ empId: 120, nombre: 'Pablo', apellidos: 'Moreno Herrera', oficio: 'ANALISTA', salario: 3500, deptId: 30 },
{ empId: 121, nombre: 'Raquel', apellidos: 'Molina Prieto', oficio: 'PROGRAMADOR', salario: 2800, deptId: 30 },
{ empId: 122, nombre: 'Iván', apellidos: 'Castro Méndez', oficio: 'PROGRAMADOR', salario: 2600, deptId: 30 },
{ empId: 123, nombre: 'Sara', apellidos: 'Ortiz Delgado', oficio: 'PROGRAMADOR', salario: 2400, deptId: 30 },
{ empId: 124, nombre: 'Hugo', apellidos: 'Rubio Marín', oficio: 'TÉCNICO SISTEMAS', salario: 2500, deptId: 30 },
{ empId: 130, nombre: 'Isabel', apellidos: 'Domínguez Vega', oficio: 'CONTABLE', salario: 2600, deptId: 40 },
{ empId: 131, nombre: 'Jorge', apellidos: 'Vázquez Ramos', oficio: 'ADMINISTRATIVO', salario: 1650, deptId: 40 },
{ empId: 140, nombre: 'Nuria', apellidos: 'Gil Soto', oficio: 'DISEÑADOR', salario: 2300, deptId: 50 },
{ empId: 141, nombre: 'Álvaro', apellidos: 'Peña Cano', oficio: 'ANALISTA', salario: 2700, deptId: 50 },
{ empId: 150, nombre: 'Rosa', apellidos: 'Iglesias Cruz', oficio: 'OPERARIO', salario: 1600, deptId: 60 },
{ empId: 151, nombre: 'Manuel', apellidos: 'Herrero Lozano', oficio: 'OPERARIO', salario: 1600, deptId: 60 },
{ empId: 152, nombre: 'Cristina', apellidos: 'Medina Blanco', oficio: 'ADMINISTRATIVO', salario: 1750, deptId: 60 },
{ empId: 160, nombre: 'Tomás', apellidos: 'Reyes Fuentes', oficio: 'BECARIO', salario: 900, deptId: null },
];
export function buscarEmpleado(id: number): Empleado | undefined {
return EMPLEADOS.find((e) => e.empId === id);
}
export function buscarDepartamento(id: number): Departamento | undefined {
return DEPARTAMENTOS.find((d) => d.deptId === id);
}
3. Definir rutas
Cada Route se compone, como mínimo, de un path y de lo que hay que hacer con él. El path no lleva barra inicial: se escribe 'empleados', no '/empleados'. Si la pones, el enrutador rechaza la configuración al arrancar.
| Patrón | Encaja con | No encaja con |
|---|---|---|
'' | / | /empleados |
'empleados' | /empleados | /empleados/110 |
'empleados/:id' | /empleados/110, /empleados/ana | /empleados, /empleados/110/x |
'departamentos/:id/empleados' | /departamentos/30/empleados | /departamentos/30 |
'**' | Cualquier cosa | Nada |
Lo que empieza por dos puntos es un parámetro: acepta cualquier valor en ese segmento y lo captura con ese nombre. Un segmento es lo que hay entre dos barras. :id no comprueba que sea un número: /empleados/ana encaja, y validarlo es cosa tuya (sección 6).
La primera que encaja gana
El enrutador recorre la tabla de arriba abajo y se detiene en la primera coincidencia. No busca la mejor, ni la más concreta.
Figura 2. El orden de la tabla decide qué ruta se usa. El enrutador no se detiene a buscar la más concreta.
El error clásico sale de ahí. Imagina esta tabla:
export const routes: Routes = [
{ path: 'empleados/:id', component: DetalleEmpleado },
{ path: 'empleados/nuevo', component: NuevoEmpleado }, // nunca se alcanza
];
/empleados/nuevo encaja con la primera ruta, con id igual a 'nuevo', y la segunda jamás se usa. No hay ningún error: simplemente sale la ficha de un empleado inexistente. La regla es sencilla: lo concreto primero, lo genérico después, y el comodín al final.
pathMatch: prefix y full
Por defecto, una ruta encaja si su path coincide con el principio de la URL (pathMatch: 'prefix'). Lo que sobre se intenta casar con sus rutas hijas; si no tiene hijas y sobran segmentos, esa ruta no encaja y se sigue buscando. Con pathMatch: 'full' debe coincidir la URL entera.
Solo notarás la diferencia en las redirecciones, y en especial con el path vacío, porque toda URL empieza por la cadena vacía. Lo veremos en la sección 11.
4. Navegar
routerLink, no href
Un enlace normal dentro de la aplicación funciona, pero mal:
<!-- Mal: recarga la aplicación entera y pierde el estado -->
<a href="/empleados">Empleados</a>
<!-- Bien: navega sin recargar -->
<a routerLink="/empleados">Empleados</a>
La directiva RouterLink escribe por ti el atributo href y cancela el clic normal. Gracias a ese href real siguen funcionando el clic derecho, Ctrl+clic para abrir en otra pestaña y los lectores de pantalla. Para usarla, el componente debe importarla.
Hay dos formas de escribir el destino. Con texto fijo basta el atributo; si hay datos variables, se enlaza un array de segmentos:
<a routerLink="/empleados">Empleados</a>
<a [routerLink]="['/empleados', e.empId]">{{ e.nombre }}</a>
<a [routerLink]="['/departamentos', d.deptId, 'empleados']">Plantilla</a>
El array se une con barras, y cada elemento se codifica para que sea seguro en una URL. Evita concatenar texto a mano, como '/empleados/' + id: si el valor llevara una barra, se rompería la ruta.
Rutas absolutas y relativas
Lo que empieza por / es absoluto: se resuelve desde la raíz. Lo demás es relativo a la ruta del componente que contiene el enlace, no a la URL que se ve en el navegador. Es el mismo razonamiento que en un sistema de ficheros:
| Destino | Desde /departamentos/30 | Resultado |
|---|---|---|
routerLink="empleados" | Dentro de la ruta :id | /departamentos/30/empleados |
routerLink="./empleados" | Dentro de la ruta :id | /departamentos/30/empleados |
routerLink=".." | Dentro de la ruta :id | /departamentos |
routerLink="/empleados" | Da igual dónde esté | /empleados |
Los enlaces relativos hacen que un bloque de rutas se pueda mover a otra parte de la aplicación sin tocar sus enlaces, y por eso son los preferidos dentro de una zona. Los absolutos convienen para saltar entre zonas.
Un detalle que despista: .. quita un segmento de la URL, no una ruta entera. Una ruta con path: 'z/:id' son dos segmentos en una sola ruta, así que desde /z/1 el enlace ../2 lleva a /z/2, y no a /2. Lo hemos comprobado con el href generado.
Parámetros de consulta y fragmentos
queryParams añade la parte ?clave=valor, y fragment la parte #ancla. Este enlace:
<a routerLink="/x/1" [queryParams]="{ a: 1 }" fragment="sec">Ir</a>
produce este HTML real:
<a href="/x/1?a=1#sec">Ir</a>
queryParamsHandling indica qué hacer con los parámetros de consulta que ya hay en la URL:
| Valor | Efecto |
|---|---|
'' (por defecto) | Se descartan los actuales y se usan solo los nuevos |
'merge' | Se combinan; un valor null borra ese parámetro |
'preserve' | Se conservan los actuales y se ignoran los nuevos |
Sirve, por ejemplo, para un enlace de «siguiente página» que no pierda la búsqueda, como verás en la sección 7.
Elementos que no son enlaces
routerLink también funciona sobre otros elementos. En ese caso no hay href, así que Angular añade tabindex="0" para que se pueda llegar con el teclado:
<button id="b" routerlink="/x/3" tabindex="0">b</button>
Aun así, si lleva a otra pantalla, usa <a>: es lo que esperan los lectores de pantalla, y permite abrir en una pestaña nueva. Deja <button> para acciones.
Navegar desde código
Cuando el destino depende de una acción, como guardar un formulario, se inyecta Router:
import { Component, inject } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
@Component({ /* ... */ })
export class FichaEditable {
private readonly router = inject(Router);
private readonly ruta = inject(ActivatedRoute);
protected async guardar() {
// ...guardar los datos...
const hecha = await this.router.navigate(['/empleados', 110], { queryParams: { guardado: true } });
console.log(hecha ? 'Navegación hecha' : 'Navegación descartada');
}
protected irAPlantilla() {
// Relativa: hay que decir respecto a qué ruta
this.router.navigate(['empleados'], { relativeTo: this.ruta });
}
protected abrirUrlCompleta() {
this.router.navigateByUrl('/empleados?q=garcia');
}
}
| Método | Recibe | Cuándo usarlo |
|---|---|---|
navigate(comandos, extras) | Un array como routerLink | Lo habitual. Admite relativeTo, queryParams, fragment, replaceUrl… |
navigateByUrl(url, extras) | Una URL completa | Cuando ya tienes la URL entera, por ejemplo la que guardaste antes de un login. Siempre es absoluta |
Sin relativeTo, navigate resuelve desde la raíz, aunque el comando no empiece por /. Es la diferencia más traicionera con routerLink, que sí es relativo por defecto.
Los dos devuelven una promesa de boolean. Comprobado: navegar a una URL nueva resuelve true; volver a pedir la URL en la que ya estás resuelve false, porque se ignora. Si no existe ninguna ruta, la promesa se rechaza:
NG04002: Cannot match any routes. URL Segment: 'nada'
La opción replaceUrl: true sustituye la entrada actual del historial en lugar de añadir otra. Va bien en un buscador: sin ella, cada letra escrita sería una pulsación más del botón Atrás.
Mostrar otra dirección con browserUrl 🆕 v22.0
🆕 Desde Angular 22.0. No existe en la LTS (Angular 21). Si usas la LTS: la entrada
browserUrldeRouterLinkno está, pero sí la opción equivalente al navegar desde código:router.navigateByUrl('/v', { browserUrl: '/oculta' }).
A veces quieres activar una ruta y mostrar en la barra de direcciones otra distinta. El caso típico es enseñar una pantalla de error o de permiso denegado conservando la URL que el usuario pidió, para que al recargar lo intente de nuevo. Desde la 22 se puede hacer directamente en el enlace:
<a routerLink="/v" browserUrl="/oculta">Ver</a>
Comprobado al pulsarlo: router.url vale /v, que es lo que el enrutador ha activado, y Location.path() vale /oculta, que es lo que ve el usuario. El atributo href sigue siendo /v.
5. Marcar el enlace activo
RouterLinkActive añade clases CSS al elemento cuando su enlace coincide con la URL actual, y las quita cuando deja de coincidir:
<a routerLink="/empleados" routerLinkActive="activo">Empleados</a>
Por defecto, la comparación es por prefijo: /empleados queda activo también en /empleados/110, que es lo que quieres en un menú. Pero routerLink="/" sería prefijo de toda la aplicación, e Inicio estaría siempre marcado. Para esos casos está exact:
<a routerLink="/" routerLinkActive="activo" [routerLinkActiveOptions]="{ exact: true }">Inicio</a>
routerLinkActiveOptions también admite el control fino de IsActiveMatchOptions, con cuatro claves:
| Clave | Valores | Qué compara |
|---|---|---|
paths | 'exact', 'subset' | Los segmentos de la ruta |
queryParams | 'exact', 'subset', 'ignored' | Los parámetros de consulta |
matrixParams | 'exact', 'subset', 'ignored' | Los parámetros de matriz, los que van con ; en un segmento |
fragment | 'exact', 'ignored' | El fragmento #... |
Si necesitas el estado en la plantilla, la directiva se exporta como routerLinkActive:
<a routerLink="/empleados" routerLinkActive #rla="routerLinkActive">
Empleados {{ rla.isActive ? '(estás aquí)' : '' }}
</a>
Y puede ponerse en un elemento padre: se activa si lo está alguno de los enlaces que contiene, algo útil para resaltar un <li> o un grupo de menú.
Accesibilidad: ariaCurrentWhenActive
Una clase CSS solo la perciben quienes ven la pantalla. Un lector de pantalla necesita aria-current. Esta entrada lo pone y lo quita por ti:
<a routerLink="/empleados" routerLinkActive="activo" ariaCurrentWhenActive="page">Empleados</a>
Este es el HTML real con la URL en /productos/2:
<a routerlink="/" routerlinkactive="activo" ariacurrentwhenactive="page" href="/">Inicio</a>
<a routerlink="/productos" routerlinkactive="activo" ariacurrentwhenactive="page"
href="/productos" class="activo" aria-current="page">Productos</a>
💡 Consejo: Pon siempre las dos cosas juntas, clase y
ariaCurrentWhenActive. No cuesta nada y mejora la experiencia de quien usa un lector de pantalla. La accesibilidad se trata a fondo en el módulo 21.
[routerLinkActive] con null 🔄 v22.1
🔄 Cambia en Angular 22.1. En la LTS (Angular 21) la entrada solo admite
stringostring[], y pasarlenulloundefinedno compila. Si usas la LTS: convierte el valor antes:[routerLinkActive]="clase() ?? ''".
Desde la 22.1 puedes enlazar la clase a un valor que aún no existe, sin más comprobaciones:
<a routerLink="/v" [routerLinkActive]="clase()">Ver</a>
Con clase() a null, el enlace funciona y no añade ninguna clase.
Saber si una URL está activa desde código
Para calcular algo a partir de la URL actual existe la función isActive, disponible desde la 21.1. Devuelve una señal que se actualiza con cada navegación:
import { Component, inject } from '@angular/core';
import { isActive, Router } from '@angular/router';
@Component({
selector: 'app-aviso-zona',
template: `
@if (enEmpleados()) {
<p>Estás en la zona de empleados</p>
}
`,
})
export class AvisoZona {
private readonly router = inject(Router);
protected readonly enEmpleados = isActive('/empleados', this.router, {
paths: 'subset',
queryParams: 'ignored',
matrixParams: 'ignored',
fragment: 'ignored',
});
}
El método router.isActive(), que verás en código anterior, está obsoleto desde la 21.1: devuelve un valor suelto que no avisa de los cambios.
Comparar dos URL con containsTree 🆕 v22.2
🆕 Desde Angular 22.2. No existe en la LTS (Angular 21). Si usas la LTS: no hay equivalente público para comparar dos URL cualesquiera. Si una de ellas es la actual, usa
isActive(url, router, opciones), que sí existe en la 21.
containsTree compara dos UrlTree, el objeto en que el enrutador convierte una URL, sin que ninguna tenga que ser la actual. Devuelve si la primera contiene a la segunda:
import { containsTree, Router } from '@angular/router';
const a = router.parseUrl('/departamentos/30/empleados?pagina=2');
const b = router.parseUrl('/departamentos/30');
containsTree(a, b); // true: a está dentro de la zona b
containsTree(b, a); // false: b es más corta que a
containsTree(a, b, { paths: 'exact' }); // false: las rutas no son idénticas
containsTree(a, router.parseUrl('/departamentos/30?pagina=3')); // false: pagina no coincide
Son los resultados reales. Los valores por defecto son paths: 'subset', queryParams: 'subset', matrixParams: 'ignored' y fragment: 'ignored'.
6. Parámetros de ruta en input()
Para mostrar la ficha de /empleados/110, el componente necesita saber ese 110. Con la característica withComponentInputBinding(), que ya activamos en app.config.ts, el enrutador lo escribe en un input() con el mismo nombre:
// src/app/empleados/detalle-empleado.ts
import { Component, computed, input, numberAttribute } from '@angular/core';
import { RouterLink } from '@angular/router';
import { buscarDepartamento, buscarEmpleado } from '../datos';
@Component({
selector: 'app-detalle-empleado',
imports: [RouterLink],
template: `
@if (empleado(); as e) {
<h1>{{ e.nombre }} {{ e.apellidos }}</h1>
<dl>
<dt>Oficio</dt>
<dd>{{ e.oficio }}</dd>
<dt>Salario</dt>
<dd>{{ e.salario }} €</dd>
<dt>Departamento</dt>
<dd>
@if (departamento(); as d) {
<a [routerLink]="['/departamentos', d.deptId]">{{ d.nombre }}</a>
} @else {
Sin asignar
}
</dd>
</dl>
<p class="saltos">
<a [routerLink]="['/empleados', id() - 10]">Anterior</a>
<a [routerLink]="['/empleados', id() + 10]">Siguiente</a>
</p>
} @else {
<h1>No existe el empleado {{ id() }}</h1>
}
<a routerLink="/empleados" queryParamsHandling="preserve">Volver a la lista</a>
`,
styles: `
dl { display: grid; grid-template-columns: 9rem 1fr; gap: .25rem; }
dt { color: #78909c; }
dd { margin: 0; }
.saltos { display: flex; gap: 1rem; }
`,
})
export class DetalleEmpleado {
// Llega de empleados/:id
readonly id = input.required({ transform: numberAttribute });
protected readonly empleado = computed(() => buscarEmpleado(this.id()));
protected readonly departamento = computed(() =>
buscarDepartamento(this.empleado()?.deptId ?? 0),
);
constructor() {
console.log('DetalleEmpleado creado');
}
}
Tres ideas de este código:
- Todo lo que llega de la URL es texto.
transform: numberAttributeconvierte'110'en110. Sin él,buscarEmpleadocompararía con===un texto y un número y no encontraría nunca nada. TypeScript no te avisaría si declarasinput<number>(), porque el enrutador escribe el valor en tiempo de ejecución, sin comprobar tipos. La prueba: uninput(1)que recibe?pag=7datypeofigual a'string'. - Los datos derivan del parámetro con
computed. Es la parte clave, como verás enseguida. - Hay que contar con un
idque no exista: un usuario puede escribir cualquier cosa en la barra de direcciones. Aquí mostramos un aviso. Si el texto no es numérico,numberAttributedevuelveNaN, y el aviso sale con ese valor.

Figura 3. La ficha en /empleados/120. El título de la pestaña del navegador también cambia, como verás en la sección 10.
📘 Concepto: Con
withComponentInputBinding(), el enrutador rellena losinput()del componente de la ruta con cuatro fuentes: parámetros de ruta, parámetros de consulta,datade la ruta y lo que devuelvan los resolvers del módulo 11. Si dos fuentes usan el mismo nombre, ganadata, luego el parámetro de ruta y por último el de consulta. Con{ path: 'choque/:valor', data: { valor: 'data' } }y la URL/choque/ruta?valor=consulta, elinputvalorrecibe'data'.
Angular reutiliza el componente
Con la ficha abierta, pulsa «Siguiente». La URL pasa de /empleados/110 a /empleados/120, la pantalla cambia, y sin embargo en la consola no aparece de nuevo «DetalleEmpleado creado». Lo hemos medido en el navegador: al pulsar Siguiente, cero componentes nuevos.
Cuando una navegación apunta a la misma ruta y solo cambian los parámetros, Angular conserva la instancia y actualiza sus datos. Es más rápido, pero tiene una consecuencia: lo que ejecutes una sola vez al crear el componente no se repite. Compara estas dos versiones:
// Mal: lee el parámetro una vez
export class TrampaSnapshot implements OnInit {
private readonly ruta = inject(ActivatedRoute);
protected nombre = '';
ngOnInit() {
this.nombre = this.ruta.snapshot.paramMap.get('id') ?? '';
}
}
Lo comprobamos navegando de /x/1 a /x/2: ngOnInit se ejecuta una sola vez y la pantalla sigue mostrando 1 aunque la URL diga 2. Es uno de los errores más habituales al empezar, porque en una prueba rápida, abriendo una sola ficha, funciona.
Con un input() y computed, el dato deriva de una señal que el enrutador actualiza, y todo se recalcula solo. Por eso empleado es un computed y no una asignación en el constructor.
⚠️ Importante: No leas en
constructorni enngOnInitnada que dependa de la URL y deba actualizarse. Derívalo de una señal concomputed. Si debes cargar datos cuando cambie, usahttpResource(módulo 13), que se relanza solo.
La trampa de los valores por defecto
Este es el fallo menos intuitivo de withComponentInputBinding, y conviene conocerlo bien. Mira este componente:
@Component({
selector: 'app-defecto',
template: `q={{ q() }} pag={{ pag() }}`,
})
export class Defecto {
readonly q = input('por-defecto');
readonly pag = input(1);
}
Cabría esperar q=por-defecto pag=1 al abrir /def. Esta es la salida real:
/def -> q= pag=
/def?q=x&pag=7 -> q=x pag=7
Cuando un input() no encuentra su nombre en los datos de la ruta, el enrutador le escribe undefined, por encima del valor por defecto. El tipo dice string, pero en ejecución vale undefined. La razón de ese diseño es evitar datos caducados: al pasar de /lista?q=abc a /lista, si no se escribiera nada, el input seguiría valiendo 'abc'.
La solución que funciona en todas las versiones es usar transform, que recibe también ese undefined:
// Con transform, el valor por defecto se aplica de verdad
readonly pagina = input(1, {
transform: (valor: string | number | undefined) => numberAttribute(valor, 1),
});
// O bien, declarar sin más que puede faltar
readonly q = input<string>();
Opciones de withComponentInputBinding 🆕 v22.0
🆕 Desde Angular 22.0. No existe en la LTS (Angular 21). Si usas la LTS:
withComponentInputBinding()no admite argumentos. Conserva los valores por defecto contransform, y evita que un parámetro de consulta llegue a uninputponiéndole a este otro nombre.
Desde la 22 la función acepta un objeto ComponentInputBindingOptions con dos opciones:
provideRouter(
routes,
withComponentInputBinding({
queryParams: false,
unmatchedInputBehavior: 'undefinedIfStale',
}),
);
queryParams: false deja de pasar los parámetros de consulta a los input(). Conviene si no quieres que un parámetro añadido a mano en la URL acabe en un input del componente que casualmente se llame igual: con { queryParams: false }, ?q=hola no llega a q.
unmatchedInputBehavior decide qué hacer cuando un input no tiene dato:
| Valor | Comportamiento |
|---|---|
'alwaysUndefined' (por defecto) | Escribe siempre undefined, como acabamos de ver |
'undefinedIfStale' | Solo escribe undefined si ese input tuvo un dato antes en la misma ruta |
Con undefinedIfStale, y q = input('por defecto') y modo = input('compacto'), esta es la salida real:
/v -> q=por defecto modo=compacto
/v?q=hola -> q=hola modo=compacto
/v -> q= modo=compacto
Fíjate en la tercera línea. El input modo nunca recibió nada y conserva su valor por defecto, pero q vuelve a undefined, no a 'por defecto'. Así que, incluso con esta opción, transform sigue siendo necesario si un parámetro puede aparecer y desaparecer.
La alternativa: ActivatedRoute
ActivatedRoute es el objeto que describe la ruta activa de un componente. Lo verás en mucho código, y sigue siendo necesario en situaciones como un servicio o un componente que no es el de la ruta, donde no llega el input. Expone observables, que se convierten en señales con toSignal:
import { Component, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { ActivatedRoute } from '@angular/router';
import { map } from 'rxjs';
@Component({
selector: 'app-con-ruta',
template: `{{ id() }}/{{ orden() }}`,
})
export class ConRuta {
private readonly ruta = inject(ActivatedRoute);
protected readonly id = toSignal(this.ruta.paramMap.pipe(map((p) => p.get('id'))));
protected readonly orden = toSignal(this.ruta.queryParamMap.pipe(map((q) => q.get('orden'))));
}
En /y/7?orden=precio muestra 7/precio. toSignal y los operadores se explican en los módulos 14 y 15.
| Propiedad | Tipo | Contenido |
|---|---|---|
paramMap | Observable<ParamMap> | Parámetros de ruta |
queryParamMap | Observable<ParamMap> | Parámetros de consulta |
fragment | Observable<string \| null> | Fragmento de la URL |
data | Observable<Data> | Datos estáticos y resueltos |
url | Observable<UrlSegment[]> | Segmentos de esta ruta |
snapshot | ActivatedRouteSnapshot | Foto del estado actual, sin actualizarse |
snapshot no es un error en sí: va bien para valores que no pueden cambiar mientras el componente vive, o en sitios que se ejecutan una vez por navegación, como las funciones de título de la sección 10.
Datos fijos en la ruta
La propiedad data asocia información estática a una ruta, y también llega a los input():
{ path: 'ayuda', component: Ayuda, data: { seccion: 'general' } }
export class Ayuda {
readonly seccion = input<string>();
}
Así un mismo componente puede servir a dos rutas con distinto comportamiento.
7. La URL como estado: filtros y paginación
Un buscador con su texto guardado en una señal se pierde al recargar y no se puede compartir. Si lo guardas en la URL, todo eso se resuelve sin escribir más: el botón Atrás deshace filtros, y un enlace a /empleados?q=r&pagina=2 abre exactamente esa vista.
El patrón tiene dos sentidos: leer de los input() y escribir navegando. El componente no guarda una copia propia del estado.
// src/app/empleados/lista-empleados.ts
import { Component, computed, inject, input, numberAttribute } from '@angular/core';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { EMPLEADOS } from '../datos';
const TAMANO = 6;
@Component({
selector: 'app-lista-empleados',
imports: [RouterLink],
template: `
<h1>Empleados</h1>
<input
type="search"
placeholder="Buscar por apellido u oficio"
aria-label="Buscar"
[value]="q() ?? ''"
(input)="buscar($any($event.target).value)"
/>
<ul class="lista">
@for (e of visibles(); track e.empId) {
<li>
<a [routerLink]="['/empleados', e.empId]" queryParamsHandling="preserve">
{{ e.apellidos }}, {{ e.nombre }}
</a>
<small>{{ e.oficio }}</small>
</li>
} @empty {
<li>Ningún empleado coincide con la búsqueda.</li>
}
</ul>
<nav class="paginas" aria-label="Paginación">
@if (paginaActual() > 1) {
<a [routerLink]="[]" [queryParams]="{ pagina: paginaActual() - 1 }" queryParamsHandling="merge">Anterior</a>
}
<span>Página {{ paginaActual() }} de {{ totalPaginas() }}</span>
@if (paginaActual() < totalPaginas()) {
<a [routerLink]="[]" [queryParams]="{ pagina: paginaActual() + 1 }" queryParamsHandling="merge">Siguiente</a>
}
</nav>
`,
styles: `
input { padding: .4rem .6rem; width: 20rem; max-width: 100%; }
.lista { list-style: none; padding: 0; }
.lista li { padding: .35rem 0; border-bottom: 1px solid #eceff1; display: flex; justify-content: space-between; max-width: 34rem; }
small { color: #78909c; }
.paginas { display: flex; gap: 1rem; margin-top: .75rem; }
`,
})
export default class ListaEmpleados {
private readonly router = inject(Router);
private readonly ruta = inject(ActivatedRoute);
// Llegan de ?q=...&pagina=... gracias a withComponentInputBinding
readonly q = input<string>();
readonly pagina = input(1, {
transform: (valor: string | number | undefined) => numberAttribute(valor, 1),
});
private readonly filtrados = computed(() => {
const texto = (this.q() ?? '').toLowerCase();
return EMPLEADOS.filter((e) =>
`${e.apellidos} ${e.oficio}`.toLowerCase().includes(texto),
);
});
protected readonly totalPaginas = computed(() =>
Math.max(1, Math.ceil(this.filtrados().length / TAMANO)),
);
protected readonly paginaActual = computed(() =>
Math.min(this.pagina(), this.totalPaginas()),
);
protected readonly visibles = computed(() => {
const inicio = (this.paginaActual() - 1) * TAMANO;
return this.filtrados().slice(inicio, inicio + TAMANO);
});
protected buscar(texto: string) {
this.router.navigate([], {
relativeTo: this.ruta,
queryParams: { q: texto || null, pagina: null },
queryParamsHandling: 'merge',
replaceUrl: true,
});
}
}
La clase se exporta con export default por una razón que verás en la sección 8. Los detalles que hacen funcionar el conjunto:
[routerLink]="[]"significa «quédate en esta misma ruta». Solo cambian los parámetros de consulta.queryParamsHandling="merge"en la paginación conservaq, y cambia únicamentepagina.- En
buscar,pagina: nullborra el parámetro. Al cambiar la búsqueda hay que volver a la primera página. q: texto || nullquitaqde la URL cuando la caja queda vacía, y deja una URL limpia.queryParamsHandling="preserve"en cada fila lleva el filtro a la ficha, y el enlace «Volver a la lista» lo devuelve. Así, al volver se conserva la búsqueda.paginaActualprotege contra valores absurdos escritos a mano. Comprobado:/empleados?pagina=99muestra «Página 4 de 4».

Figura 4. Lista en /empleados?q=r&pagina=2. Se puede copiar esa dirección y abrirla en otra pestaña: se verá lo mismo.
Así evoluciona la URL en la prueba con el navegador:
/empleados Abrimos la lista
/empleados?q=r Escribimos "r" en el buscador
/empleados?q=r&pagina=2 Pulsamos Siguiente
/empleados/110?q=r&pagina=2 Abrimos la primera ficha
💡 Consejo: Guarda en la URL lo que define qué se está viendo: filtros, orden, página, pestaña. Deja en señales lo efímero: si un menú está abierto, o qué fila tiene el ratón encima. La prueba es fácil: si al compartir el enlace el otro debería ver lo mismo, va en la URL.
8. Carga perezosa
Si importas todos los componentes en app.routes.ts, todos acaban en el fichero main.js que el navegador descarga al entrar, aunque el usuario nunca abra esas pantallas. La carga perezosa (lazy loading) reparte la aplicación en trozos que se descargan cuando hacen falta.
El mecanismo no es de Angular: es el import() dinámico de JavaScript, que devuelve una promesa con el módulo. Al ver un import(), el compilador separa ese código en otro fichero. Puedes repasar los módulos de JavaScript en el Curso de JavaScript.
loadComponent: un componente
{
path: 'empleados/:id',
title: tituloEmpleado,
loadComponent: () =>
import('./empleados/detalle-empleado').then((m) => m.DetalleEmpleado),
},
Si el componente es la exportación por defecto del fichero, se puede ahorrar el .then. Angular busca default por sí mismo, y por eso ListaEmpleados lleva export default:
{
path: 'empleados',
title: 'Empleados',
loadComponent: () => import('./empleados/lista-empleados'),
},
Si escribes esa forma corta con un fichero que no tiene exportación por defecto, el compilador lo detiene con un TS2322, porque el módulo no es del tipo que espera loadComponent.
⚠️ Importante: Si importas el componente arriba del fichero con un
importnormal, por ejemplo para usarlo en otra parte, deja de ser perezoso: el código se queda enmain.js. Un soloimportestático basta para anularlo.
loadChildren: toda una zona
Con loadChildren se carga un fichero de rutas completo. Es lo adecuado para una zona con varias pantallas. Las rutas de departamentos viven en su propio fichero, y su export default evita de nuevo el .then:
{
path: 'departamentos',
loadChildren: () => import('./departamentos/departamentos.routes'),
},
// src/app/departamentos/departamentos.routes.ts
import { Routes } from '@angular/router';
import { DepartamentoMarco } from './departamento-marco';
import { DepartamentoEmpleados } from './departamento-empleados';
import { DepartamentoResumen } from './departamento-resumen';
import { ListaDepartamentos } from './lista-departamentos';
export default [
{ path: '', component: ListaDepartamentos, title: 'Departamentos' },
{
path: ':id',
component: DepartamentoMarco,
children: [
{ path: '', redirectTo: 'resumen', pathMatch: 'full' },
{ path: 'resumen', component: DepartamentoResumen, title: 'Resumen del departamento' },
{ path: 'empleados', component: DepartamentoEmpleados, title: 'Plantilla del departamento' },
],
},
] satisfies Routes;
Aquí los import normales son correctos: todo este fichero ya es perezoso, y sus componentes viajan en el mismo trozo. satisfies Routes comprueba el tipo sin cambiarlo. Si prefieres no usar export default, vale igual una constante con nombre y .then((m) => m.rutasDepartamentos).
Lo que produce ng build
Esta es la salida real de EMPRESA:
Initial chunk files | Names | Raw size | Estimated transfer size
main-N5ODPPKQ.js | main | 235.05 kB | 63.97 kB
styles-5INURTSO.css | styles | 0 bytes | 0 bytes
| Initial total | 235.05 kB | 63.97 kB
Lazy chunk files | Names | Raw size | Estimated transfer size
chunk-D3R0D5oY.js | departamentos-routes | 3.48 kB | 1.18 kB
chunk-DKIOzAOU.js | lista-empleados | 2.83 kB | 1.24 kB
chunk-CtM0ORkL.js | detalle-empleado | 1.89 kB | 896 bytes
Cada import() ha generado su fichero. En la prueba con el navegador, al abrir la aplicación no se pidió ninguno de ellos, y el de la lista llegó justo al pulsar «Empleados».
Figura 5. Reparto del código de EMPRESA según la salida de ng build.
En esta aplicación pequeña la diferencia es modesta, porque casi todo el peso es Angular. En una real, con decenas de pantallas, formularios y tablas, la carga inicial puede reducirse mucho.
La contrapartida es una pequeña espera la primera vez que se abre cada pantalla, y por eso App muestra «Cargando…» mientras currentNavigation() no es null. Si quieres eliminar esa espera, la precarga descarga estos trozos en segundo plano, como verás en el módulo 11. Para diferir partes de una misma pantalla, está @defer en el módulo 23.
💡 Consejo: Deja en
main.jssolo lo que ve todo el mundo nada más entrar, y la página 404. Lo demás, perezoso.InicioyNoEncontradase importan de forma normal porque no merece la pena separar pantallas tan ligeras.
9. Rutas hijas
Muchas pantallas tienen partes fijas y otras que cambian. Un departamento tiene un título y unas pestañas, y debajo un contenido según la pestaña. Las rutas hijas (child routes) modelan esto con children, y necesitan otro <router-outlet> dentro del componente padre:
// src/app/departamentos/departamento-marco.ts
import { Component, computed, input, numberAttribute } from '@angular/core';
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import { buscarDepartamento } from '../datos';
@Component({
selector: 'app-departamento-marco',
imports: [RouterLink, RouterLinkActive, RouterOutlet],
template: `
<h1>{{ departamento()?.nombre ?? 'Departamento desconocido' }}</h1>
<nav class="pestanas" aria-label="Secciones del departamento">
<a routerLink="resumen" routerLinkActive="activa" ariaCurrentWhenActive="page">Resumen</a>
<a routerLink="empleados" routerLinkActive="activa" ariaCurrentWhenActive="page">Plantilla</a>
</nav>
<section class="panel">
<router-outlet />
</section>
<a routerLink="..">Todos los departamentos</a>
`,
styles: `
.pestanas { display: flex; gap: .25rem; border-bottom: 2px solid #c5cae9; }
.pestanas a { padding: .4rem .9rem; color: #283593; text-decoration: none; }
.pestanas a.activa { background: #e8eaf6; border-bottom: 2px solid #ff7043; margin-bottom: -2px; font-weight: 600; }
.panel { padding: .75rem 0; }
`,
})
export class DepartamentoMarco {
readonly id = input.required({ transform: numberAttribute });
protected readonly departamento = computed(() => buscarDepartamento(this.id()));
}
Las pestañas usan enlaces relativos: routerLink="resumen" lleva a /departamentos/30/resumen sea cual sea el departamento. Las hijas son componentes corrientes:
// src/app/departamentos/departamento-resumen.ts
import { Component, computed, input, numberAttribute } from '@angular/core';
import { buscarDepartamento, EMPLEADOS } from '../datos';
@Component({
selector: 'app-departamento-resumen',
template: `
<p>Sede: {{ departamento()?.localidad }}</p>
<p>Personas en plantilla: {{ plantilla() }}</p>
`,
})
export class DepartamentoResumen {
// :id es de la ruta padre: llega por herencia de parámetros
readonly id = input(0, { transform: (valor: string | undefined) => numberAttribute(valor, 0) });
protected readonly departamento = computed(() => buscarDepartamento(this.id()));
protected readonly plantilla = computed(
() => EMPLEADOS.filter((e) => e.deptId === this.id()).length,
);
}
// src/app/departamentos/departamento-empleados.ts
import { Component, computed, input, numberAttribute } from '@angular/core';
import { RouterLink } from '@angular/router';
import { EMPLEADOS } from '../datos';
@Component({
selector: 'app-departamento-empleados',
imports: [RouterLink],
template: `
<ul>
@for (e of empleados(); track e.empId) {
<li><a [routerLink]="['/empleados', e.empId]">{{ e.nombre }} {{ e.apellidos }}</a></li>
} @empty {
<li>Este departamento no tiene empleados.</li>
}
</ul>
`,
})
export class DepartamentoEmpleados {
readonly id = input(0, { transform: (valor: string | undefined) => numberAttribute(valor, 0) });
protected readonly empleados = computed(() =>
EMPLEADOS.filter((e) => e.deptId === this.id()),
);
}
// src/app/departamentos/lista-departamentos.ts
import { Component } from '@angular/core';
import { RouterLink } from '@angular/router';
import { DEPARTAMENTOS } from '../datos';
@Component({
selector: 'app-lista-departamentos',
imports: [RouterLink],
template: `
<h1>Departamentos</h1>
<ul>
@for (d of departamentos; track d.deptId) {
<li><a [routerLink]="[d.deptId]">{{ d.nombre }}</a> ({{ d.localidad }})</li>
}
</ul>
`,
})
export class ListaDepartamentos {
protected readonly departamentos = DEPARTAMENTOS;
}

Figura 6. /departamentos/30/empleados. El menú superior marca Departamentos y la pestaña marca Plantilla: dos niveles de enlaces activos a la vez.
Figura 7. Un <router-outlet> por cada nivel de anidamiento.
La URL completa se forma uniendo los path del padre y de la hija. Y al moverte entre pestañas, DepartamentoMarco no se destruye: solo cambia lo que hay en su outlet.
⚠️ Importante: Si la ruta tiene
childreny olvidas el<router-outlet>en la plantilla del padre, no hay ningún error. Lo hemos comprobado: la navegación termina bien, la URL cambia, el padre se pinta y la hija ni siquiera se crea. Si «no sale nada», es lo primero que hay que mirar.
Hija por defecto
Con /departamentos/30 el padre encaja, pero ninguna hija. Sin nada más, se verían el título y las pestañas con el panel vacío. Hay dos soluciones: una hija con path: '', que se muestra directamente, o una redirección a la pestaña inicial. En EMPRESA hemos usado la segunda, para que la pestaña activa se vea en la URL. Comprobado: /departamentos/30 termina en /departamentos/30/resumen.
Rutas sin componente
Un padre no está obligado a tener componente. Sirve para agrupar rutas bajo un prefijo sin añadir ningún nivel a la pantalla, y entonces sus hijas se muestran en el outlet del abuelo:
{
path: 'productos',
children: [
{ path: '', component: Catalogo },
{ path: ':id', component: FichaProducto },
],
},
Herencia de parámetros 🔄 v22.0
🔄 Cambia en Angular 22.0. En la LTS (Angular 21)
paramsInheritanceStrategyvale'emptyOnly': una ruta hija conpathpropio no ve los parámetros del padre. Si usas la LTS: añadewithRouterConfig({ paramsInheritanceStrategy: 'always' })aprovideRouterpara tener el mismo comportamiento.
¿Cómo sabe DepartamentoResumen su id, si :id pertenece a la ruta padre? Desde Angular 22, cada ruta hereda siempre los parámetros y los datos de sus antecesoras.
Figura 8. Las dos estrategias de herencia para la misma ruta.
Este módulo se ha ejecutado en las dos versiones, con la misma aplicación y sin configuración adicional:
| URL | Angular 22 | Angular 21 |
|---|---|---|
/departamentos/30/resumen | Sede: Valencia, Personas en plantilla: 6 | Sede: vacío, Personas en plantilla: 0 |
/departamentos/30/empleados | Lista de 6 empleados | «Este departamento no tiene empleados.» |
Sin error de ningún tipo: la aplicación simplemente muestra datos vacíos. Con withRouterConfig la versión 21 da los mismos resultados:
// Solo si usas Angular 21
import { provideRouter, withComponentInputBinding, withRouterConfig } from '@angular/router';
provideRouter(
routes,
withComponentInputBinding(),
withRouterConfig({ paramsInheritanceStrategy: 'always' }),
);
En la 21, con 'emptyOnly', una hija hereda solo si su path está vacío o si el padre no tiene componente.
Otra forma de escribirlo, válida en todas las versiones, es que la hija lo lea del padre con inject(ActivatedRoute).parent, aunque es más incómodo.
Si un nombre se repite, gana la hija. En d/:id con una hija o/:id, la URL /d/30/o/99 da id=99 en la hija. Con parámetros de varios niveles, usa nombres distintos, como :deptId y :empId, para no llevarte sorpresas.
⚠️ Importante: Al actualizar de la 21 a la 22,
ng updateno aplica ninguna migración para esto: no está entre las que incluye@angular/core. Tu aplicación pasa a heredar sin que nadie lo diga. Normalmente no molesta, pero si dependías de que uninputde la hija estuviera vacío o tienes parámetros repetidos, revísalo. Para conservar el comportamiento antiguo, configuraparamsInheritanceStrategy: 'emptyOnly'. La migración de la 21 a la 22 se cuenta paso a paso en el módulo 28.
10. Títulos de página
El título de la pestaña del navegador es importante para usuarios con varias pestañas, para los marcadores, para el historial y para los lectores de pantalla, que lo anuncian al cambiar de pantalla. La propiedad title de la ruta lo actualiza en cada navegación.
Título fijo y título calculado
{ path: '', component: Inicio, title: 'Inicio' },
Si depende de los datos, title admite una función ResolveFn<string>. Recibe la ruta que se va a activar y puede devolver un texto, una promesa o un observable:
import { ResolveFn } from '@angular/router';
import { buscarEmpleado } from './datos';
const tituloEmpleado: ResolveFn<string> = (ruta) => {
const empleado = buscarEmpleado(Number(ruta.paramMap.get('id')));
return empleado ? `${empleado.nombre} ${empleado.apellidos}` : 'Empleado desconocido';
};
Resultados comprobados: /empleados/110 da «Carmen Ruiz Ortega» y /empleados/9999 da «Empleado desconocido».
Aquí el snapshot que recibe ruta es correcto, ya que la función se ejecuta en cada navegación. Lo que devuelve se espera antes de activar la ruta, así que, si hace una llamada lenta, retrasa la navegación. Si en una ruta anidada hay varios títulos, se usa el de la ruta más profunda que lo tenga.
Un formato común con TitleStrategy
Para añadir « | EMPRESA» a todos los títulos no hace falta repetirlo: se sustituye la estrategia. TitleStrategy es una clase abstracta con un método updateTitle, y buildTitle hace el trabajo de encontrar el título de la ruta:
// src/app/titulo-empresa.ts
import { inject, Injectable } from '@angular/core';
import { Title } from '@angular/platform-browser';
import { RouterStateSnapshot, TitleStrategy } from '@angular/router';
@Injectable()
export class TituloEmpresa extends TitleStrategy {
private readonly titulo = inject(Title);
override updateTitle(estado: RouterStateSnapshot) {
const propio = this.buildTitle(estado);
this.titulo.setTitle(propio ? `${propio} | EMPRESA` : 'EMPRESA');
}
}
// En app.config.ts, dentro de providers
{ provide: TitleStrategy, useClass: TituloEmpresa },
La técnica de sustituir una clase con useClass es la del módulo 08. Con esta estrategia, /productos da «Catálogo | EMPRESA» en el ejercicio 6.
11. Redirecciones
Redirección fija
redirectTo envía a otra URL sin dibujar nada. Sirve para direcciones antiguas o para una pantalla por defecto:
{ path: 'plantilla', redirectTo: 'empleados', pathMatch: 'full' },
Comprobado: /plantilla termina en /empleados, y el usuario no llega a ver la intermedia.
Aquí pathMatch sí importa. Con el valor por defecto, prefix, la redirección se aplica también a las URL más largas y conserva lo que sobra. Con { path: 'a', redirectTo: 'b' }, la URL /a/c acaba en /b/c.
El caso extremo es el path vacío, porque toda URL empieza por nada: todas se redirigirían. Angular lo detecta al arrancar y lanza este error:
NG04014: Invalid configuration of route '{path: "", redirectTo: "x"}': please provide 'pathMatch'. The default value of 'pathMatch' is 'prefix', but often the intent is to use 'full'.
Parámetros en la redirección
Puedes reutilizar en el destino los parámetros de la ruta original. Los parámetros de consulta y el fragmento se conservan solos. Comprobado con { path: 'p/:id', redirectTo: 'q/:id' }:
/p/4?orden=x#f -> /q/4?orden=x#f
Pero las redirecciones absolutas no los conservan. Con { path: 'r', redirectTo: '/b', pathMatch: 'full' }:
/r?k=1 -> /b
Si los necesitas, usa la redirección relativa o una función.
Redirección calculada
redirectTo admite también una función de tipo RedirectFunction. Recibe los datos de la ruta y devuelve un texto o un UrlTree:
import { RedirectFunction } from '@angular/router';
const redirigirFicha: RedirectFunction = ({ params, queryParams }) => {
const id = Number(params['id']);
return Number.isInteger(id) ? `/empleados/${id}` : `/empleados?q=${queryParams['q'] ?? ''}`;
};
{ path: 'personal/:id', redirectTo: redirigirFicha },
Resultados comprobados: /personal/110 lleva a /empleados/110, y /personal/ana?q=gar lleva a /empleados?q=gar. Si debes codificar texto libre en una URL así, construye un UrlTree con router.createUrlTree en lugar de concatenar. Para obtener el Router en la función, usa inject(Router): se ejecuta en contexto de inyección.
📘 Concepto:
redirectTotraduce una dirección en otra. Para decidir si un usuario puede entrar, y mandarlo al login si no, la herramienta es un guard, en el módulo 11.
12. Comodín y página 404
El comodín ** encaja con cualquier URL. Puesto al final de la tabla, recoge lo que no ha encajado antes:
// src/app/paginas/no-encontrada.ts
import { Component, inject } from '@angular/core';
import { Router, RouterLink } from '@angular/router';
@Component({
selector: 'app-no-encontrada',
imports: [RouterLink],
template: `
<h1>Página no encontrada</h1>
<p>La dirección <code>{{ url }}</code> no corresponde a ninguna pantalla.</p>
<a routerLink="/">Volver al inicio</a>
`,
})
export class NoEncontrada {
protected readonly url = inject(Router).url;
}

Figura 9. La cabecera sigue presente porque la página 404 es una ruta más dentro del <router-outlet>.
Es mejor mostrar el componente directamente que redirigir a una dirección /404: así se conserva la URL que pidió el usuario y puede corregirla. Una redirección perdería la pista de lo que falló.
La tabla completa
Con todo lo visto, esta es la tabla de rutas de EMPRESA:
// src/app/app.routes.ts
import { RedirectFunction, ResolveFn, Routes } from '@angular/router';
import { buscarEmpleado } from './datos';
import { Inicio } from './paginas/inicio';
import { NoEncontrada } from './paginas/no-encontrada';
const tituloEmpleado: ResolveFn<string> = (ruta) => {
const empleado = buscarEmpleado(Number(ruta.paramMap.get('id')));
return empleado ? `${empleado.nombre} ${empleado.apellidos}` : 'Empleado desconocido';
};
const redirigirFicha: RedirectFunction = ({ params, queryParams }) => {
const id = Number(params['id']);
return Number.isInteger(id) ? `/empleados/${id}` : `/empleados?q=${queryParams['q'] ?? ''}`;
};
export const routes: Routes = [
{ path: '', component: Inicio, title: 'Inicio' },
{
path: 'empleados',
title: 'Empleados',
loadComponent: () => import('./empleados/lista-empleados'),
},
{
path: 'empleados/:id',
title: tituloEmpleado,
loadComponent: () =>
import('./empleados/detalle-empleado').then((m) => m.DetalleEmpleado),
},
{
path: 'departamentos',
loadChildren: () => import('./departamentos/departamentos.routes'),
},
{ path: 'plantilla', redirectTo: 'empleados', pathMatch: 'full' },
{ path: 'personal/:id', redirectTo: redirigirFicha },
{ path: '**', component: NoEncontrada, title: 'Página no encontrada' },
];
Falta solo la pantalla inicial:
// src/app/paginas/inicio.ts
import { Component } from '@angular/core';
import { RouterLink } from '@angular/router';
import { DEPARTAMENTOS, EMPLEADOS } from '../datos';
@Component({
selector: 'app-inicio',
imports: [RouterLink],
template: `
<h1>Panel de EMPRESA</h1>
<p>
Hay <a routerLink="/empleados">{{ empleados }} empleados</a> repartidos en
<a routerLink="/departamentos">{{ departamentos }} departamentos</a>.
</p>
`,
})
export class Inicio {
protected readonly empleados = EMPLEADOS.length;
protected readonly departamentos = DEPARTAMENTOS.length;
}
Scroll y URL con almohadilla
Dos características que resuelven problemas muy comunes:
withInMemoryScrolling. Por defecto, al navegar el enrutador no toca el scroll: si estabas abajo en una lista larga, la pantalla nueva aparece también desplazada. Con scrollPositionRestoration: 'enabled', va arriba al avanzar y recupera la posición al pulsar Atrás. Con anchorScrolling: 'enabled', las URL con #fragmento saltan al elemento con ese id. Conviene activarlo en casi todas las aplicaciones.
withHashLocation. Cambia el estilo de las URL de /empleados a /#/empleados:
provideRouter(routes, withHashLocation());
Lo que va tras # nunca llega al servidor, así que este siempre recibe la raíz y entrega index.html, sin configuración. Es un recurso para sitios donde no puedes configurar el servidor. Las URL son más feas y peores para los buscadores, de modo que, si puedes, usa las normales. Las opciones de despliegue están en el módulo 27.
13. Depuración y errores frecuentes
Cuando una navegación no hace lo que esperas, activa la traza temporalmente:
provideRouter(routes, withDebugTracing());
La consola mostrará cada evento (NavigationStart, RoutesRecognized, GuardsCheckStart, NavigationEnd…), y en ellos verás qué ruta se ha reconocido. Quítala antes de publicar. La extensión Angular DevTools del módulo 01 muestra además el árbol de componentes para ver qué hay dentro de cada outlet.
| Síntoma | Causa | Solución |
|---|---|---|
NG04002: Cannot match any routes | La URL no encaja y no hay comodín | Corrige el enlace o añade ** al final |
NG04014: Invalid configuration of route | redirectTo con path: '' sin pathMatch | Añade pathMatch: 'full' |
NG8001: 'router-outlet' is not a known element | Falta importar RouterOutlet en el componente | Añádelo a imports |
NG8002: Can't bind to 'routerLink' | Falta importar RouterLink y usas [routerLink] | Añádelo a imports |
| El enlace no hace nada y no hay error | Falta importar RouterLink y usas routerLink="...": el compilador lo toma por un atributo normal | Añádelo a imports |
TS2322 en loadComponent | El fichero no tiene export default | Añade .then((m) => m.Clase) |
| La ruta hija no aparece, sin errores | Falta <router-outlet> en el padre | Añádelo a su plantilla |
| Sale una pantalla equivocada | Una ruta genérica va antes que la concreta | Reordena la tabla |
| Todo redirige siempre al mismo sitio | path: '' con pathMatch: 'prefix', o comodín por delante | Revisa el orden y pathMatch |
La pantalla no cambia al navegar de /x/1 a /x/2 | Se leyó snapshot en ngOnInit | Deriva de un input() con computed |
Un input vale undefined pese a tener valor por defecto | El enrutador escribe undefined si falta el dato | Usa transform (sección 6) |
El input de una hija llega vacío solo en la 21 | Estrategia emptyOnly | withRouterConfig (sección 9) |
El input no se rellena en ningún caso | Falta withComponentInputBinding(), o el componente no es el de la ruta | Actívala, o pásalo desde el padre |
| Funciona navegando, pero F5 da 404 en producción | El servidor no devuelve index.html | Configura el servidor (módulo 27) |
Los .js dan 404 al abrir una ruta profunda | <base href> incorrecto | Ajusta base href |
14. Ejercicios
Para hacer los ejercicios añade a datos.ts estas constantes, tomadas de la API de EMPRESA. Hemos dejado sin existencias el producto 5 para poder filtrar:
export interface Producto {
productoId: number;
nombre: string;
precio: number;
stock: number;
}
export const PRODUCTOS: Producto[] = [
{ productoId: 1, nombre: 'Portátil UltraBook 14', precio: 899, stock: 25 },
{ productoId: 2, nombre: 'Portátil Gaming 16', precio: 1499, stock: 10 },
{ productoId: 3, nombre: 'Portátil Básico 15', precio: 499, stock: 40 },
{ productoId: 4, nombre: 'Torre Oficina i5', precio: 649, stock: 15 },
{ productoId: 5, nombre: 'Torre Workstation', precio: 1899, stock: 0 },
];
export const CLIENTES = [
{ clienteId: 1001, nombre: 'Talleres Martín S.L.', ciudad: 'Valencia' },
{ clienteId: 1002, nombre: 'Construcciones Levante S.A.', ciudad: 'Alicante' },
];
export const PEDIDOS = [
{ pedidoId: 5001, clienteId: 1001, estado: 'ENTREGADO' },
{ pedidoId: 5002, clienteId: 1002, estado: 'ENTREGADO' },
{ pedidoId: 5006, clienteId: 1001, estado: 'ENTREGADO' },
{ pedidoId: 5021, clienteId: 1001, estado: 'ENVIADO' },
];
Ejercicio 1: Menú accesible
Crea un componente Menu con enlaces a Inicio, Productos y Clientes. El enlace de la página actual debe llevar la clase activo y anunciarse a los lectores de pantalla. Inicio solo debe marcarse en /.
Ver solución
import { Component } from '@angular/core';
import { RouterLink, RouterLinkActive } from '@angular/router';
@Component({
selector: 'app-menu',
imports: [RouterLink, RouterLinkActive],
template: `
<nav aria-label="Principal">
<a routerLink="/" routerLinkActive="activo" [routerLinkActiveOptions]="{ exact: true }" ariaCurrentWhenActive="page">Inicio</a>
<a routerLink="/productos" routerLinkActive="activo" ariaCurrentWhenActive="page">Productos</a>
<a routerLink="/clientes" routerLinkActive="activo" ariaCurrentWhenActive="page">Clientes</a>
</nav>
`,
})
export class Menu {}
Sin exact, Inicio estaría marcado siempre, porque / es prefijo de todas las URL. En Productos no queremos exact, para que siga marcado en /productos/2. Comprobado en esa URL: solo Productos lleva class="activo" y aria-current="page".
Ejercicio 2: Ficha de producto
Crea la ruta productos/:id con un componente FichaProducto. Debe recibir id como número en un input, mostrar el nombre, el precio y el stock, un aviso si el producto no existe y un enlace relativo para volver al catálogo.
Ver solución
import { Component, computed, input, numberAttribute } from '@angular/core';
import { RouterLink } from '@angular/router';
import { PRODUCTOS } from '../datos';
@Component({
selector: 'app-ficha-producto',
imports: [RouterLink],
template: `
@if (producto(); as p) {
<h1>{{ p.nombre }}</h1>
<p>{{ p.precio }} €, {{ p.stock }} en almacén</p>
} @else {
<h1>Producto {{ id() }} no encontrado</h1>
}
<a routerLink="..">Volver al catálogo</a>
`,
})
export class FichaProducto {
readonly id = input.required({ transform: numberAttribute });
protected readonly producto = computed(() => PRODUCTOS.find((p) => p.productoId === this.id()));
}
Salidas reales: /productos/2 muestra «Portátil Gaming 16», «1499 €, 10 en almacén»; /productos/99 muestra «Producto 99 no encontrado». Recuerda activar withComponentInputBinding().
Ejercicio 3: Catálogo con el estado en la URL
Crea Catalogo en /productos. Debe ordenar por nombre o por precio según ?orden= y filtrar los agotados con ?disponibles=true. Los enlaces deben conservar los demás parámetros. Un orden no válido debe tratarse como nombre.
Ver solución
import { Component, computed, input } from '@angular/core';
import { RouterLink } from '@angular/router';
import { PRODUCTOS } from '../datos';
type Orden = 'nombre' | 'precio';
@Component({
selector: 'app-catalogo',
imports: [RouterLink],
template: `
<h1>Catálogo</h1>
<p>
Ordenar por:
<a [routerLink]="[]" [queryParams]="{ orden: 'nombre' }" queryParamsHandling="merge">nombre</a> |
<a [routerLink]="[]" [queryParams]="{ orden: 'precio' }" queryParamsHandling="merge">precio</a> |
<a [routerLink]="[]" [queryParams]="{ disponibles: soloDisponibles() ? null : true }" queryParamsHandling="merge">
{{ soloDisponibles() ? 'Ver todos' : 'Solo disponibles' }}
</a>
</p>
<ul>
@for (p of productos(); track p.productoId) {
<li><a [routerLink]="[p.productoId]">{{ p.nombre }}</a> ({{ p.precio }} €)</li>
}
</ul>
`,
})
export class Catalogo {
readonly orden = input<Orden, string | undefined>('nombre', {
transform: (valor) => (valor === 'precio' ? 'precio' : 'nombre'),
});
readonly disponibles = input<string>();
protected readonly soloDisponibles = computed(() => this.disponibles() === 'true');
protected readonly productos = computed(() => {
const lista = PRODUCTOS.filter((p) => !this.soloDisponibles() || p.stock > 0);
return this.orden() === 'precio'
? lista.sort((a, b) => a.precio - b.precio)
: lista.sort((a, b) => a.nombre.localeCompare(b.nombre));
});
}
sort modifica el array sobre el que actúa, pero aquí es seguro porque filter ya ha creado una copia. El transform cubre a la vez el valor ausente y el no válido. Con /productos?orden=precio&disponibles=true la lista real es: Portátil Básico 15 (499 €), Torre Oficina i5 (649 €), Portátil UltraBook 14 (899 €) y Portátil Gaming 16 (1499 €). La Workstation no aparece por no tener existencias.
Ejercicio 4: Pestañas de cliente
Crea /clientes/:id con dos pestañas, datos y pedidos, en rutas hijas. La dirección sin pestaña debe llevar a datos.
Ver solución
import { Component, computed, input, numberAttribute } from '@angular/core';
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import { CLIENTES, PEDIDOS } from '../datos';
@Component({
selector: 'app-cliente-marco',
imports: [RouterLink, RouterLinkActive, RouterOutlet],
template: `
<h1>{{ cliente()?.nombre ?? 'Cliente desconocido' }}</h1>
<nav>
<a routerLink="datos" routerLinkActive="activa">Datos</a>
<a routerLink="pedidos" routerLinkActive="activa">Pedidos</a>
</nav>
<router-outlet />
`,
})
export class ClienteMarco {
readonly id = input.required({ transform: numberAttribute });
protected readonly cliente = computed(() => CLIENTES.find((c) => c.clienteId === this.id()));
}
@Component({
selector: 'app-cliente-datos',
template: `<p>Ciudad: {{ cliente()?.ciudad }}</p>`,
})
export class ClienteDatos {
readonly id = input(0, { transform: (v: string | undefined) => numberAttribute(v, 0) });
protected readonly cliente = computed(() => CLIENTES.find((c) => c.clienteId === this.id()));
}
@Component({
selector: 'app-cliente-pedidos',
template: `
<ul>
@for (p of pedidos(); track p.pedidoId) {
<li>Pedido {{ p.pedidoId }}: {{ p.estado }}</li>
}
</ul>
`,
})
export class ClientePedidos {
readonly id = input(0, { transform: (v: string | undefined) => numberAttribute(v, 0) });
protected readonly pedidos = computed(() => PEDIDOS.filter((p) => p.clienteId === this.id()));
}
{
path: 'clientes/:id',
component: ClienteMarco,
children: [
{ path: '', redirectTo: 'datos', pathMatch: 'full' },
{ path: 'datos', component: ClienteDatos },
{ path: 'pedidos', component: ClientePedidos },
],
},
/clientes/1001 termina en /clientes/1001/datos y muestra «Ciudad: Valencia». En /clientes/1001/pedidos salen los pedidos 5001, 5006 y 5021. En Angular 21 los dos paneles salen vacíos si no añades withRouterConfig({ paramsInheritanceStrategy: 'always' }).
Ejercicio 5: Zona perezosa
Saca los productos a un fichero productos.routes.ts con export default y cárgalo con loadChildren. Comprueba con ng build que aparece un trozo nuevo y que Catalogo ya no está en main.
Ver solución
// src/app/productos/productos.routes.ts
import { Routes } from '@angular/router';
import { Catalogo } from './catalogo';
import { FichaProducto } from './ficha-producto';
export default [
{ path: '', component: Catalogo, title: 'Catálogo' },
{ path: ':id', component: FichaProducto },
] satisfies Routes;
// En app.routes.ts
{
path: 'productos',
loadChildren: () => import('./productos/productos.routes'),
},
La ruta productos no tiene componente propio: agrupa las hijas bajo el prefijo. Si algún fichero importa Catalogo con un import estático desde fuera de la zona, el código volverá a main aunque no haya error. En la tabla de ng build, busca productos-routes bajo Lazy chunk files.
Ejercicio 6: Título dinámico con sufijo
Haz que /productos/:id tenga como título el nombre del producto o «Producto desconocido», y que todos los títulos terminen en ` | EMPRESA`. |
Ver solución
import { ResolveFn, Routes } from '@angular/router';
import { PRODUCTOS } from '../datos';
import { Catalogo } from './catalogo';
import { FichaProducto } from './ficha-producto';
const tituloProducto: ResolveFn<string> = (ruta) => {
const id = Number(ruta.paramMap.get('id'));
return PRODUCTOS.find((p) => p.productoId === id)?.nombre ?? 'Producto desconocido';
};
export default [
{ path: '', component: Catalogo, title: 'Catálogo' },
{ path: ':id', component: FichaProducto, title: tituloProducto },
] satisfies Routes;
Para el sufijo, usa la clase TituloEmpresa de la sección 10 y regístrala con { provide: TitleStrategy, useClass: TituloEmpresa }. Títulos reales: «Catálogo | EMPRESA», «Portátil Gaming 16 | EMPRESA» y «Producto desconocido | EMPRESA».
Ejercicio 7: Direcciones antiguas
La aplicación anterior usaba /ficha?emp=110. Redirige esa dirección a /empleados/110. Si falta emp o no es válido, debe ir a /empleados.
Ver solución
import { RedirectFunction } from '@angular/router';
const redirigirFicha: RedirectFunction = ({ queryParams }) => {
const emp = Number(queryParams['emp']);
return Number.isInteger(emp) && emp > 0 ? `/empleados/${emp}` : '/empleados';
};
{ path: 'ficha', pathMatch: 'full', redirectTo: redirigirFicha },
Un redirectTo de texto solo puede reutilizar parámetros de ruta, y emp es de consulta: por eso hace falta la función. Comprobado: /ficha?emp=110 acaba en /empleados/110, y /ficha en /empleados.
Ejercicio 8: 404 dentro de una zona
En las rutas del cliente, una pestaña inexistente, como /clientes/1001/facturas, debe mostrar un aviso dentro del marco, sin perder el nombre del cliente ni las pestañas.
Ver solución
Añade un comodín como última hija. Se dibuja en el outlet del padre:
import { Component } from '@angular/core';
import { RouterLink } from '@angular/router';
@Component({
selector: 'app-seccion-inexistente',
imports: [RouterLink],
template: `
<p>Esta sección del cliente no existe.</p>
<a routerLink="../datos">Ir a los datos</a>
`,
})
export class SeccionInexistente {}
children: [
{ path: '', redirectTo: 'datos', pathMatch: 'full' },
{ path: 'datos', component: ClienteDatos },
{ path: 'pedidos', component: ClientePedidos },
{ path: '**', component: SeccionInexistente },
],
Resultado real en /clientes/1001/facturas: «Talleres Martín S.L.», las pestañas y el aviso. La URL se mantiene y el enlace apunta a /clientes/1001/datos. El .. es necesario porque el enlace se resuelve desde la ruta **.
Ejercicio 9 (propuesto): Filtros que no se pierden
En DetalleEmpleado, los enlaces Anterior y Siguiente pierden q y pagina: abre una ficha desde /empleados?q=r&pagina=2, pulsa Siguiente y comprueba que «Volver a la lista» ya no recupera el filtro. Corrígelo con una sola propiedad en cada enlace.
Ejercicio 10 (propuesto): Guardar y volver
Añade a la ficha un botón «Marcar como revisado» que, desde código, navegue a la lista con ?revisado=110, y haz que la lista muestre un aviso con ese dato. Escribe en la consola el boolean que devuelve navigate, y comprueba qué pasa si pulsas dos veces.
Ejercicio 11 (propuesto): Preparar la aplicación para la LTS
Copia la aplicación a un proyecto de Angular 21 y compílala. Localiza las pantallas que se quedan vacías y arréglalas de dos maneras: primero con withRouterConfig, y después sin tocar la configuración, leyendo el parámetro desde ActivatedRoute.parent. ¿Cuál prefieres si la aplicación tiene veinte rutas hijas?
Resumen
| Concepto | API | Idea clave |
|---|---|---|
| Configuración | provideRouter(routes, ...features) | Las características with... se añaden según se necesitan |
| Hueco de la pantalla | <router-outlet />, RouterOutlet | El componente se dibuja a su lado; lo de fuera se mantiene |
| Ruta | { path, component } | path sin barra inicial; :nombre captura un segmento |
| Orden | La tabla, de arriba abajo | Gana la primera; lo concreto antes, el comodín al final |
| Enlace | routerLink, [routerLink]="[...]" | Escribe el href y evita la recarga |
| Relativo o absoluto | /x, x, .. | Sin barra se resuelve desde la ruta del componente |
| Consulta | [queryParams], queryParamsHandling | merge conserva y combina; null borra un parámetro |
| Desde código | router.navigate, navigateByUrl | Promesa de boolean; navigate necesita relativeTo para ser relativo |
| Otra dirección visible | browserUrl 🆕 v22.0 en el enlace | Activa una ruta y muestra otra URL |
| Enlace activo | routerLinkActive, exact | exact para Inicio; por defecto compara prefijos |
| Accesibilidad | ariaCurrentWhenActive="page" | Siempre junto con la clase |
| Estado activo en código | isActive(url, router, opciones) | Señal que cambia al navegar; router.isActive() está obsoleto |
| Comparar URL | containsTree 🆕 v22.2 | Compara dos UrlTree cualesquiera |
Parámetros en input | withComponentInputBinding() | Une datos de ruta, parámetros y consulta; llegan como texto |
| Valores por defecto | input(x, { transform }) | Sin dato se escribe undefined: transform lo corrige |
Opciones de withComponentInputBinding | queryParams, unmatchedInputBehavior 🆕 v22.0 | Quitan la consulta y conservan valores por defecto |
| Reutilización | computed a partir de input() | Con otro :id no se crea un componente nuevo |
| Lectura manual | ActivatedRoute, toSignal | snapshot no se actualiza |
| URL como estado | [routerLink]="[]", replaceUrl | Lee de input, escribe navegando |
| Carga perezosa | loadComponent, loadChildren | Cada import() es un fichero; export default ahorra el .then |
| Progreso | router.currentNavigation() | Señal con la navegación en curso |
| Rutas hijas | children y otro <router-outlet> | Sin outlet no hay error, pero tampoco hija |
| Herencia | paramsInheritanceStrategy 🔄 v22.0 | always en 22; emptyOnly en 21 |
| Título | title, ResolveFn<string>, TitleStrategy | Fijo, calculado o con formato común |
| Redirección | redirectTo, pathMatch, RedirectFunction | pathMatch: 'full' con path: ''; la función decide en ejecución |
| Página 404 | { path: '**', component } | Última de la tabla; conserva la URL pedida |
| Scroll | withInMemoryScrolling | Restaura la posición y salta a los fragmentos |
URL con # | withHashLocation() | Solo si no puedes configurar el servidor |
| Depuración | withDebugTracing() | Muestra cada paso de la navegación |