6. Router, lazy loading, HTTP y comunicación con el backend
Una aplicación de una sola página tiene un problema que no existía en la web clásica: el servidor ya no decide qué se ve en cada URL, así que la aplicación debe reconstruir por su cuenta todo lo que el navegador daba gratis (direcciones compartibles, botón atrás, recarga, título de la pestaña, historial). Ese es el trabajo del Router. Y como los datos ya no vienen incrustados en el HTML, hay que ir a buscarlos: ese es el trabajo de HttpClient. Este capítulo cubre las dos mitades y, sobre todo, la costura entre ellas: guards, resolvers, carga diferida, interceptores, caché, tiempo real y gestión de estado.
6.1 Qué vas a poder hacer al terminar
- Explicar el ciclo completo de una navegación, en qué fase actúa cada tipo de guard y por qué el orden importa cuando algo no funciona.
- Configurar
provideRoutercon las features adecuadas y justificar cada una. - Escribir guards y resolvers funcionales, y decidir entre
canActivateycanMatchcon criterio, no por costumbre. - Dividir la aplicación en chunks por funcionalidad, medir el resultado y precargar con una estrategia propia.
- Leer parámetros de ruta sin caer en el error del
snapshotobsoleto. - Construir una cadena de interceptores de producción: autenticación, refresco de token sin condiciones de carrera, reintentos con retroceso, indicador de carga y caché.
- Diseñar una capa de datos con DTO, modelo de dominio, mapeadores, cancelación y deduplicación.
- Elegir entre polling, SSE y WebSocket, y entre un servicio con señales y NgRx, con argumentos.
- Explicar CORS, XSRF y dónde guardar un token sin repetir los mitos habituales.
6.2 El router: qué problema resuelve y cómo funciona por dentro
En una web tradicional, cada URL provoca una petición y el servidor devuelve un documento distinto. En una SPA solo hay un documento: index.html. Si no hiciéramos nada, la aplicación entera viviría en / y perderíamos cosas que los usuarios dan por supuestas.
index.html), pero el recepcionista lee la referencia que trae el visitante («planta 3, despacho B, con cita previa»), comprueba que tiene permiso, avisa de que va a subir, prepara la sala si hace falta y solo entonces le deja pasar. La calle es el navegador; el edificio, tu aplicación. Lo que aporta el Router de Angular, y que tendrías que reimplementar a mano si no lo usaras:
- URLs reales y compartibles. Cada estado significativo de la interfaz tiene una dirección que se puede copiar, marcar como favorita, indexar y abrir en otra pestaña.
- Integración con el historial mediante la History API: los botones atrás y adelante funcionan sin recargar la página.
- Composición de la interfaz: rutas anidadas que se corresponden con layouts anidados.
- Puntos de extensión declarativos en el momento exacto de la navegación: guards para autorizar, resolvers para precargar datos, estrategias para el scroll y el título.
- Frontera natural para dividir el código: la unidad de carga diferida es, casi siempre, la ruta.
6.2.1 Anatomía de una URL y el UrlTree
El router no trabaja con cadenas de texto, sino con una estructura de datos: el UrlTree. Lo produce el UrlSerializer al analizar la URL y es también lo que se serializa de vuelta al navegar. Nunca es un array plano de segmentos, porque una URL puede describir varias zonas de la pantalla a la vez.
/admin/tareas;vista=lista/42?estado=abierta&pagina=2#comentarios
─────┬───── ──────┬─────── ─┬─ ───────────┬─────────── ─────┬─────
│ │ │ │ │
│ │ │ │ └ fragment
│ │ │ └ queryParams: del ÁRBOL, no del segmento
│ │ └ UrlSegment { path: '42', parameters: {} }
│ └ UrlSegment { path: 'tareas', parameters: { vista: 'lista' } }
└ UrlSegment { path: 'admin', parameters: {} } ▲
└ parámetros de matriz
UrlTree
├── root : UrlSegmentGroup
│ └── children
│ primary : UrlSegmentGroup [ admin, tareas;vista=lista, 42 ]
│ lateral : UrlSegmentGroup [ ayuda ] ← outlet auxiliar
├── queryParams : { estado: 'abierta', pagina: '2' }
└── fragment : 'comentarios'Parámetros de matriz (;vista=lista): pertenecen a un segmento concreto. Son jerárquicos y sobreviven a la navegación relativa. Son parte del estándar de URI, aunque casi nadie los usa fuera de Angular.
Query params (?estado=abierta): pertenecen a toda la URL, son globales y por defecto se pierden al navegar salvo que pidas conservarlos.
Fragmento (#comentarios): nunca se envía al servidor. Sirve para anclas y, en el modo withHashLocation(), para la ruta completa.
6.2.2 RouterState, ActivatedRoute y sus instantáneas
Si el UrlTree es «la dirección analizada», el RouterState es «el edificio ocupado»: un árbol de ActivatedRoute, uno por cada ruta activa, que refleja exactamente la jerarquía de router-outlet montada en pantalla.
RouterState Árbol de componentes
─────────── ───────────────────
root (ActivatedRoute) AppComponent
└── 'admin' └── AdminLayoutComponent
└── 'tareas' └── ListaTareasComponent
└── ':id' └── DetalleTareaComponent
Cada nodo expone: url · params · queryParams · fragment · data · title · outlet
parent · children · firstChild · pathFromRoot · snapshotCada propiedad existe en dos formas y esa dualidad es la fuente de la mitad de los errores del capítulo:
| Forma | Tipo | Qué representa | Cuándo usarla |
|---|---|---|---|
route.paramMap |
Observable<ParamMap> |
El valor a lo largo del tiempo, emitiendo en cada cambio | Por defecto: siempre correcto |
route.snapshot.paramMap |
ParamMap |
Una fotografía del instante en que se lee | Solo si el componente se recrea en cada cambio, o dentro de un guard o resolver |
Los guards y los resolvers reciben siempre instantáneas (ActivatedRouteSnapshot y RouterStateSnapshot) y ahí es correcto: se ejecutan en un punto concreto del tiempo y terminan. Un componente, en cambio, puede sobrevivir a varias navegaciones.
6.2.3 El ciclo de navegación completo, fase a fase
Una navegación no es una asignación de URL: es una secuencia asíncrona y cancelable de fases. El router la modela como un flujo RxJS y emite eventos en cada frontera, lo que hace que se pueda observar, medir y depurar.
0. DISPARADOR
routerLink · router.navigate() · navigateByUrl() · botón atrás (popstate)
│
▼
1. NavigationStart UrlSerializer.parse(url) ─► UrlTree
│
▼
2. RECONOCIMIENTO (redirecciones + emparejamiento de rutas)
┌──────────────────────────────────────────────────────────────────┐
│ ▸ se aplican los redirectTo │
│ ▸ se recorre la configuración de rutas en ORDEN │
│ ▸ aquí se ejecutan los guards canMatch │
│ ▸ aquí se DESCARGAN los chunks de loadChildren (son necesarios │
│ para conocer las rutas hijas y poder emparejar) │
└──────────────────────────────────────────────────────────────────┘
RoutesRecognized sin coincidencia ─► NavigationError (NG04002)
│
▼
3. GuardsCheckStart
a) canDeactivate de la ruta MÁS PROFUNDA hacia la raíz (lo que se abandona)
b) canActivateChild de la raíz hacia abajo (lo que se entra)
c) canActivate de la raíz hacia abajo
GuardsCheckEnd
false ─────────► NavigationCancel (se queda donde estaba)
UrlTree ─────────► NavigationCancel + NUEVA navegación (redirección)
│ todos true
▼
4. ResolveStart ─► resolvers de la rama ─► ResolveEnd
error o EMPTY ───► NavigationError / NavigationCancel
│
▼
5. CARGA DE COMPONENTES diferidos (loadComponent) ← después de los guards:
│ si canActivate deniega,
▼ este chunk NO se descarga
6. ACTIVACIÓN
destruye los componentes que salen · crea los que entran
ChildActivationStart/End · ActivationStart/End
│
▼
7. Se confirma la URL en el navegador (urlUpdateStrategy: 'deferred' por defecto)
NavigationEnd ─► Scroller (restauración de scroll) ─► TitleStrategy1. Un canMatch que devuelve false no cancela la navegación: hace que el router siga probando las rutas siguientes. Un canActivate que devuelve false sí la cancela.
2. Los resolvers retrasan la fase 6: mientras se resuelven, el usuario sigue viendo la pantalla anterior sin ningún indicador. Es la razón número uno de que una aplicación «se sienta lenta».
3. Con urlUpdateStrategy: 'deferred', si un guard tarda dos segundos la barra de direcciones no cambia hasta el final. Con 'eager' cambia antes, lo que mejora la percepción pero deja la URL «adelantada» si la navegación se cancela.
6.2.4 Los eventos del router como herramienta de diagnóstico
import {inject} from '@angular/core';
import {
Router, NavigationStart, NavigationEnd, NavigationCancel,
NavigationError, NavigationSkipped, NavigationCancellationCode,
} from '@angular/router';
import {filter} from 'rxjs';
export function registrarTelemetriaNavegacion(): void {
const router = inject(Router);
const inicio = new Map<number, number>();
router.events.subscribe((evento) => {
if (evento instanceof NavigationStart) {
inicio.set(evento.id, performance.now());
} else if (evento instanceof NavigationEnd) {
const ms = performance.now() - (inicio.get(evento.id) ?? 0);
// Métrica útil de verdad: cuánto tarda una navegación de principio a fin,
// incluyendo guards, resolvers y descarga de chunks.
console.info(`[nav] ${evento.urlAfterRedirects} en ${ms.toFixed(0)} ms`);
inicio.delete(evento.id);
} else if (evento instanceof NavigationCancel) {
// code distingue un guard que devolvió false de una redirección con UrlTree
const motivo = evento.code === NavigationCancellationCode.GuardRejected
? 'un guard la rechazó'
: 'redirección o navegación superpuesta';
console.warn(`[nav] cancelada ${evento.url}: ${motivo}`);
} else if (evento instanceof NavigationError) {
console.error(`[nav] error en ${evento.url}`, evento.error);
} else if (evento instanceof NavigationSkipped) {
// Misma URL con onSameUrlNavigation: 'ignore'. No es un fallo.
console.debug(`[nav] omitida ${evento.url}`);
}
});
}El mismo flujo sirve para reaccionar a la navegación desde un componente, por ejemplo para cerrar el menú lateral: router.events.pipe(filter((e): e is NavigationEnd => e instanceof NavigationEnd), takeUntilDestroyed()). Y ahí aparece el riesgo que se explica a continuación.
router.events es un observable que nunca completa: vive tanto como la aplicación. Si te suscribes desde un componente sin takeUntilDestroyed(), el componente destruido seguirá referenciado por la suscripción y no se liberará nunca. En un componente reutilizado en muchas rutas, esto acumula copias del árbol de vistas hasta que la pestaña se ralentiza. 6.3 Configuración: provideRouter y sus features
Desde Angular 15 el router se configura con la función provideRouter(routes, ...features) en lugar de RouterModule.forRoot(). La diferencia no es cosmética: cada feature es una función independiente, de modo que lo que no activas no entra en el bundle. Con el módulo, todas las opciones viajaban siempre.
import {ApplicationConfig, provideZonelessChangeDetection} from '@angular/core';
import {
provideRouter, withComponentInputBinding, withViewTransitions,
withInMemoryScrolling, withRouterConfig, withPreloading, PreloadAllModules,
withNavigationErrorHandler, Router,
} from '@angular/router';
import {provideHttpClient, withFetch, withInterceptors} from '@angular/common/http';
import {routes} from './app.routes';
import {authInterceptor, cargaInterceptor, idiomaInterceptor} from './core/http';
export const appConfig: ApplicationConfig = {
providers: [
provideZonelessChangeDetection(),
provideRouter(
routes,
// Los parámetros de ruta llegan a los @Input() del componente:
// menos boilerplate y sin suscripciones manuales.
withComponentInputBinding(),
// Transiciones nativas del navegador entre vistas. Mejora progresiva:
// donde no hay soporte, la navegación es la de siempre.
withViewTransitions({skipInitialTransition: true}),
// Restaura la posición del scroll con el botón atrás y salta a #anclas.
withInMemoryScrolling({scrollPositionRestoration: 'enabled', anchorScrolling: 'enabled'}),
withRouterConfig({
onSameUrlNavigation: 'reload', // permite "recargar" la ruta actual
paramsInheritanceStrategy: 'always', // las hijas heredan params y data
urlUpdateStrategy: 'deferred', // la URL cambia al confirmar la navegación
}),
// Descarga en segundo plano todos los chunks diferidos tras el arranque.
withPreloading(PreloadAllModules),
// Último recurso: un resolver que explota no debe dejar la app en blanco.
withNavigationErrorHandler((error) => {
console.error('Navegación fallida', error);
return inject(Router).parseUrl('/error');
}),
),
provideHttpClient(
withFetch(),
withInterceptors([authInterceptor, idiomaInterceptor, cargaInterceptor]),
),
],
};6.3.1 Qué hace cada feature y cuándo la necesitas de verdad
| Feature | Qué hace | Caso real en el que la activarías |
|---|---|---|
withComponentInputBinding() |
Asigna data, parámetros de ruta y query params a los inputs del componente enrutado que tengan el mismo nombre. |
Prácticamente siempre. Elimina la suscripción a paramMap y hace el componente testeable con un simple setInput(). |
withViewTransitions(opciones) |
Envuelve la actualización del DOM en document.startViewTransition(). |
Un catálogo donde la miniatura del producto debe «crecer» hasta la ficha. Con view-transition-name en CSS el navegador interpola solo. |
withInMemoryScrolling(opciones) |
scrollPositionRestoration devuelve el scroll al volver atrás; anchorScrolling respeta el fragmento #id. |
Un listado infinito: sin esto, el botón atrás devuelve al usuario al principio de la lista y la aplicación parece rota. Está desactivado por defecto. |
withRouterConfig(opciones) |
Ajustes finos: onSameUrlNavigation, paramsInheritanceStrategy, urlUpdateStrategy, canceledNavigationResolution. |
Un botón «Actualizar» que reejecuta los resolvers de la ruta actual necesita onSameUrlNavigation: 'reload'. |
withPreloading(estrategia) |
Descarga chunks diferidos antes de que se pidan. Por defecto no hay precarga (NoPreloading). |
Un panel de administración donde el usuario visita casi siempre las mismas cuatro secciones: PreloadAllModules las tiene listas sin coste percibido. |
withHashLocation() |
Usa HashLocationStrategy: las rutas van tras #
(/#/tareas/42). |
Despliegue en un hosting estático que no puedes configurar para que devuelva index.html en cualquier ruta. Sacrificas SEO y SSR: es el último recurso. |
withDebugTracing() |
Imprime por consola todos los eventos del router con sus datos. | Depurar «¿por qué esta navegación no llega a mi componente?». Solo en desarrollo: es muy ruidoso. |
withEnabledBlockingInitialNavigation() |
Bloquea el arranque de la aplicación hasta que la navegación inicial termina. | Obligatoria con SSR e hidratación: si el cliente arranca antes de resolver la ruta, el HTML del servidor y el del cliente no coinciden. El CLI la añade sola al habilitar SSR. |
withNavigationErrorHandler(fn) |
Punto único para tratar NavigationError; puede devolver un UrlTree para redirigir. |
Un chunk que no se descarga porque se ha desplegado una versión nueva: en vez de dejar la pantalla congelada, se redirige o se recarga. |
withHashLocation() y el servidor
El motivo por el que existe: si el usuario recarga /tareas/42, el navegador pide esa ruta al servidor, que no tiene ningún fichero ahí y responde 404. La solución correcta es configurar el servidor para que devuelva index.html en cualquier ruta desconocida (en Nginx, try_files $uri $uri/ /index.html;). El hash es el plan B cuando no tienes acceso a esa configuración. provideRouter está pensado para invocarse una sola vez en la raíz. Para añadir rutas desde una librería o un módulo cargado de forma diferida existe provideRoutes(), no una segunda llamada a provideRouter. Lo mismo aplica a provideHttpClient(): una segunda llamada en el injector de una ruta diferida crea una instancia nueva de HttpClient y tus interceptores raíz dejan de aplicarse. 6.4 Definición de rutas: todas las propiedades que importan
La configuración de rutas es un array de objetos Route que el router recorre en orden, de arriba abajo, buscando la primera coincidencia. Ese detalle explica por qué la ruta comodín va siempre al final y por qué una ruta mal colocada «se come» a las de debajo.
import {Routes} from '@angular/router';
import {ShellComponent} from './layout/shell.component';
import {authGuard, rolGuard} from './core/guards';
import {tareaResolver} from './tareas/tarea.resolver';
export const routes: Routes = [
{
path: '',
component: ShellComponent, // layout persistente con la barra y el menú
canActivate: [authGuard], // protege todo el bloque de una vez
children: [
// Ruta vacía: '' con pathMatch 'full' es la portada de este bloque.
{path: '', pathMatch: 'full', redirectTo: 'tareas'},
{
path: 'tareas',
title: 'Tareas', // lo aplica TitleStrategy
data: {icono: 'check', preload: true}, // metadatos estáticos arbitrarios
loadChildren: () => import('./tareas/tareas.routes').then((m) => m.TAREAS_ROUTES),
},
{
path: 'informes',
// Solo se descarga el chunk si el usuario tiene el rol. Con canActivate
// el código llegaría al navegador y solo después se rechazaría.
canMatch: [rolGuard('analista')],
loadChildren: () => import('./informes/informes.routes'), // export default
},
{
// Ruta SIN componente: agrupa providers y guards sin añadir un nivel de DOM.
path: 'admin',
canActivate: [rolGuard('admin')],
providers: [AdminApiService, {provide: LIMITE_PAGINA, useValue: 50}],
loadChildren: () => import('./admin/admin.routes').then((m) => m.ADMIN_ROUTES),
},
{
path: 'perfil/:id',
resolve: {perfil: perfilResolver},
loadComponent: () => import('./perfil/perfil.component').then((m) => m.PerfilComponent),
},
// Panel de ayuda en un outlet auxiliar: convive con la ruta principal.
{
path: 'ayuda',
outlet: 'lateral',
loadComponent: () => import('./ayuda/ayuda.component'),
},
],
},
{path: 'login', loadComponent: () => import('./auth/login.component')},
// Redirección permanente de una URL antigua, conservando el parámetro.
{path: 'tasks/:id', redirectTo: 'tareas/:id'},
// Comodín: SIEMPRE el último.
{path: '**', loadComponent: () => import('./errores/no-encontrado.component')},
];| Propiedad | Para qué sirve | Detalle que se pasa por alto |
|---|---|---|
path | Patrón de segmentos, con parámetros :nombre | Sin barra inicial. Existe también matcher para emparejamientos que una cadena no puede expresar. |
pathMatch | 'prefix' (por defecto) o 'full' | Solo se compara con los segmentos que quedan por consumir, no con la URL entera. |
component | Componente a instanciar en el outlet | Es incompatible con loadComponent y con redirectTo. |
loadComponent | Componente standalone diferido | Se descarga después de los guards y resolvers. |
loadChildren | Array de rutas hijas diferido | Se descarga durante el emparejamiento: solo canMatch lo evita. |
redirectTo | Redirección interna | Incompatible con canActivate (la redirección ocurre antes). Admite parámetros del path original. |
children | Subrutas que se pintan en el router-outlet del padre | Sin componente padre, sirve para agrupar sin tocar el DOM. |
outlet | Nombre del outlet destino | Por defecto 'primary'. Una ruta con outlet propio no aparece en la URL principal, sino entre paréntesis. |
data | Metadatos estáticos | Los heredan las hijas según paramsInheritanceStrategy. Se mezclan con lo que devuelven los resolvers. |
resolve | Datos que deben estar listos antes de activar | Sus claves acaban también en data, así que no las repitas. |
title | Título de la pestaña, literal o función | Lo aplica TitleStrategy; se toma el título de la ruta activa más profunda que lo declare. |
providers | Servicios con ámbito de esa rama de rutas | Crean un injector hijo: útil para estado por funcionalidad que debe morir al salir. |
runGuardsAndResolvers | Cuándo reejecutar guards y resolvers | 'paramsChange' por defecto; 'always', 'pathParamsChange', 'paramsOrQueryParamsChange' o una función. |
6.4.1 pathMatch: el error clásico de la ruta vacía
Con 'prefix', el router comprueba si los segmentos de la ruta son un prefijo de lo que queda de URL. Y la cadena vacía es prefijo de absolutamente todo. Por eso una ruta vacía con redirección y sin pathMatch: 'full' captura cualquier URL, incluida la de destino: bucle infinito.
export const routes: Routes = [
// '' es prefijo de '/tareas', de '/login' y de
// cualquier otra cosa: el router redirige a
// 'tareas', vuelve a emparejar '', redirige otra
// vez... En desarrollo Angular lo detecta y exige
// que declares pathMatch explícitamente.
{path: '', redirectTo: 'tareas'},
{path: 'tareas', component: TareasComponent},
]; export const routes: Routes = [
// 'full' exige que NO queden segmentos por
// consumir: solo empareja la URL raíz exacta.
{path: '', pathMatch: 'full', redirectTo: 'tareas'},
{path: 'tareas', component: TareasComponent},
]; pathMatch: 'full' en toda ruta vacía que redirige. 'prefix' (el comportamiento por defecto) en toda ruta vacía que tiene hijas o componente, porque justamente queremos que siga consumiendo segmentos.
El segundo caso es igual de importante: {path: '', component: LayoutComponent, children: [...]}
con pathMatch: 'full' nunca emparejaría nada que tenga subrutas.
6.4.2 Títulos de página y TitleStrategy personalizada
La propiedad title de una ruta se aplica a través de un servicio inyectable, TitleStrategy. Sustituirlo permite añadir el nombre del producto, traducir o construir títulos a partir de datos resueltos, y todo eso en un solo sitio en lugar de en cada componente.
import {Injectable, inject} from '@angular/core';
import {Title} from '@angular/platform-browser';
import {RouterStateSnapshot, TitleStrategy} from '@angular/router';
@Injectable({providedIn: 'root'})
export class TituloAplicacionStrategy extends TitleStrategy {
private readonly title = inject(Title);
override updateTitle(estado: RouterStateSnapshot): void {
// buildTitle recorre el árbol y devuelve el title de la ruta activa
// más profunda que lo defina (resolviendo funciones si es el caso).
const titulo = this.buildTitle(estado);
this.title.setTitle(titulo ? `${titulo} · Gestor de tareas` : 'Gestor de tareas');
}
}
// En los providers de la aplicación:
// {provide: TitleStrategy, useClass: TituloAplicacionStrategy}// title admite una ResolveFn<string>, así que puede inyectar servicios y leer
// lo que ya han dejado los resolvers de la misma ruta en data.
const tituloTarea: ResolveFn<string> = (ruta) =>
(ruta.data['tarea'] as Tarea | undefined)?.titulo ?? 'Detalle de tarea';
export const TAREAS_ROUTES: Routes = [
{path: '', title: 'Tareas', loadComponent: () => import('./lista.component')},
{path: ':id', resolve: {tarea: tareaResolver}, title: tituloTarea,
loadComponent: () => import('./detalle.component')},
];6.5 Parámetros: ruta, matriz, query y fragmento
6.5.1 Las tres formas de leerlos, y cuál usar
import {Component, inject} from '@angular/core';
import {ActivatedRoute} from '@angular/router';
import {takeUntilDestroyed} from '@angular/core/rxjs-interop';
import {switchMap, map} from 'rxjs';
export class DetalleTareaComponent {
private readonly route = inject(ActivatedRoute);
private readonly api = inject(TareasApi);
// paramMap emite en CADA cambio de parámetro, incluso si el componente se reutiliza.
readonly tarea$ = this.route.paramMap.pipe(
map((p) => p.get('id')!), // ParamMap: get, getAll, has, keys
switchMap((id) => this.api.obtener(id)), // switchMap cancela la petición anterior
);
// Query params: independientes del path, viven en el árbol completo.
readonly pagina$ = this.route.queryParamMap.pipe(map((q) => Number(q.get('pagina') ?? 1)));
// Parámetros de matriz del último segmento consumido por ESTA ruta:
// /tareas/42;modo=edicion ─► params incluye { id: '42', modo: 'edicion' }
readonly modo$ = this.route.params.pipe(map((p) => p['modo'] ?? 'lectura'));
}6.5.2 Cuándo falla el snapshot (y por qué)
El router reutiliza el componente cuando la configuración de ruta no cambia y solo cambian los parámetros. Es una optimización deliberada: pasar de /tareas/1 a /tareas/2 no destruye ni recrea nada, así que ngOnInit no se vuelve a ejecutar y cualquier valor leído del snapshot en el constructor se queda congelado en el primer identificador.
export class DetalleTareaComponent implements OnInit {
tarea?: Tarea;
ngOnInit(): void {
// Se lee UNA vez. Al navegar de /tareas/1 a /tareas/2
// el componente se reutiliza, ngOnInit NO se repite y
// la pantalla sigue mostrando la tarea 1 mientras la
// URL dice 2. El usuario cree que la app está rota.
const id = this.route.snapshot.paramMap.get('id')!;
this.api.obtener(id).subscribe((t) => (this.tarea = t));
}
} export class DetalleTareaComponent {
// El observable emite en cada cambio de parámetro,
// se reutilice el componente o no. AsyncPipe o toSignal
// se encargan de la suscripción y de cancelarla.
readonly tarea = toSignal(
this.route.paramMap.pipe(
map((p) => p.get('id')!),
switchMap((id) => this.api.obtener(id)),
),
{initialValue: undefined},
);
} runGuardsAndResolvers. Con el valor por defecto ('paramsChange'), al cambiar solo un query param no se reejecutan los resolvers. Si tu paginación vive en query params y esperas que el resolver vuelva a pedir datos, necesitas 'paramsOrQueryParamsChange'. Es una causa habitual de «la primera página funciona y las demás no». 6.5.3 La forma moderna: withComponentInputBinding() + input()
Con esa feature activada, el router asigna a los inputs del componente enrutado todo lo que coincida por nombre: claves de data (incluidos los resolvers), parámetros de ruta y query params. El componente deja de conocer el router, lo que además lo hace trivial de testear.
import {Component, input, computed} from '@angular/core';
import {rxResource} from '@angular/core/rxjs-interop';
@Component({
selector: 'app-detalle-tarea',
template: `
@if (tarea.isLoading()) { <app-esqueleto /> }
@else if (tarea.value(); as t) { <h2>{{ t.titulo }}</h2> }
`,
})
export class DetalleTareaComponent {
// Del path /tareas/:id. Es una señal: cambia sola en cada navegación.
readonly id = input.required<string>();
// De ?modo=edicion. Como es opcional, conviene un valor por defecto.
readonly modo = input<'lectura' | 'edicion'>('lectura');
// De resolve: {tarea: ...} o de data: {...} de la ruta.
readonly soloLectura = computed(() => this.modo() === 'lectura');
private readonly api = inject(TareasApi);
readonly tarea = rxResource({params: () => this.id(), stream: ({params}) => this.api.obtener(params)});
}withComponentInputBinding()
Colisiones de nombres. Si el mismo nombre existe como query param, como parámetro de ruta y en data, solo gana uno. No dependas de ese orden: usa nombres distintos.
Todo llega como string desde la URL. Un input<number>() recibirá '42', no 42. Usa un transform: input(0, {transform: numberAttribute}).
Solo el componente de la ruta. Los inputs se asignan al componente que el outlet instancia, no a sus hijos. Los hijos los reciben por binding normal.
Comprueba tu versión. rxResource y resource son APIs recientes y su firma ha cambiado entre versiones (la propiedad de entrada pasó de request a params, y loader convive con stream). Verifica la documentación de la versión que tengas instalada antes de copiar el ejemplo.
6.6 Navegación: enlaces, imperativa y UrlTree
6.6.1 routerLink: absoluto, relativo y con parámetros
<!-- Absoluto: empieza por '/'. Ignora dónde estemos. -->
<a routerLink="/tareas">Todas las tareas</a>
<!-- Con parámetros: array de "comandos". Angular escapa los valores por ti. -->
<a [routerLink]="['/tareas', tarea.id]">{{ tarea.titulo }}</a>
<!-- Relativo a la ruta activa: si estamos en /tareas, va a /tareas/42 -->
<a [routerLink]="[tarea.id]">Ver</a>
<!-- Relativo hacia arriba: desde /tareas/42/editar hasta /tareas/42 -->
<a routerLink="..">Volver al detalle</a>
<!-- Parámetros de matriz: /tareas;vista=compacta -->
<a [routerLink]="['/tareas', {vista: 'compacta'}]">Vista compacta</a>
<!-- Query params y fragmento. 'merge' conserva los que ya hubiera. -->
<a [routerLink]="['/tareas']"
[queryParams]="{estado: 'abierta', pagina: 1}"
queryParamsHandling="merge"
fragment="resultados">Abiertas</a>
<!-- Base relativa explícita: útil dentro de un componente reutilizable
que no sabe en qué nivel del árbol de rutas se está pintando. -->
<a [routerLink]="['detalle']" [relativeTo]="rutaBase">Detalle</a>
<!-- Estado activo. ariaCurrentWhenActive lo anuncia a los lectores de pantalla. -->
<a routerLink="/tareas"
routerLinkActive="activo"
[routerLinkActiveOptions]="{exact: true}"
ariaCurrentWhenActive="page">Inicio</a>
<!-- Coincidencia fina: el path debe ser exacto, pero los query params se ignoran. -->
<a routerLink="/informes"
routerLinkActive="activo"
[routerLinkActiveOptions]="{paths: 'exact', queryParams: 'ignored',
matrixParams: 'ignored', fragment: 'ignored'}">Informes</a>RouterLink, RouterLinkActive y RouterOutlet. Si olvidas RouterLink y escribes routerLink="/tareas" como atributo estático, Angular no da error: simplemente no hace nada, o el navegador sigue el href y recarga la aplicación completa. Con la sintaxis de binding
([routerLink]) sí obtienes un error de compilación de plantilla, que es preferible. 6.6.2 Navegación imperativa: navigate, navigateByUrl y createUrlTree
private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute);
async guardar(): Promise<void> {
const tarea = await this.api.crear(this.formulario.getRawValue());
// 1) navigate: acepta el mismo array de comandos que routerLink.
// Devuelve Promise<boolean>: false si un guard la canceló.
const ok = await this.router.navigate(['/tareas', tarea.id], {
// Reemplaza la entrada actual del historial en lugar de añadir una:
// así el botón atrás no devuelve al formulario ya enviado.
replaceUrl: true,
// Datos que viajan con la navegación sin aparecer en la URL.
state: {mensaje: 'Tarea creada correctamente'},
});
if (!ok) console.warn('La navegación fue cancelada por un guard');
}
verHermana(id: string): void {
// 2) Relativa: sube un nivel y baja al hermano. Sin relativeTo, 'navigate'
// interpreta los comandos como absolutos.
this.router.navigate(['..', id], {relativeTo: this.route});
}
filtrar(estado: string): void {
// 3) Mantener la URL y cambiar solo un query param.
this.router.navigate([], {
relativeTo: this.route,
queryParams: {estado, pagina: 1},
queryParamsHandling: 'merge', // 'preserve' mantendría los antiguos y descartaría los nuevos
});
}
irACrudo(url: string): void {
// 4) navigateByUrl: recibe una URL YA construida. No admite relativeTo
// ni queryParams: lo que le pases es literalmente la URL final.
this.router.navigateByUrl(url, {skipLocationChange: true});
}
enlaceCompartible(id: string): string {
// 5) createUrlTree + serializeUrl: construir la URL sin navegar.
// Imprescindible para "copiar enlace", canonical, o comparaciones.
const arbol = this.router.createUrlTree(['/tareas', id], {queryParams: {origen: 'email'}});
return location.origin + this.router.serializeUrl(arbol);
}NavigationExtras | Efecto | Cuándo se usa de verdad |
|---|---|---|
state | Guarda un objeto serializable en history.state | Pasar un mensaje de éxito o el origen de la navegación sin ensuciar la URL. |
skipLocationChange | Navega y activa componentes, pero no toca la barra de direcciones | Mostrar un modal enrutado sin cambiar la URL, o vistas internas de un asistente. Rompe el enlace compartible: úsalo con cuidado. |
replaceUrl | Sustituye la entrada actual del historial | Tras enviar un formulario, tras un login, o al redirigir desde una URL obsoleta. |
queryParamsHandling | 'merge' combina, 'preserve' conserva los actuales | Filtros y paginación que deben sobrevivir a la navegación entre pestañas de una misma vista. |
onSameUrlNavigation | 'reload' fuerza el ciclo aunque la URL no cambie | Botón «Actualizar» que reejecuta guards y resolvers. |
6.6.3 Leer el state de una navegación
El state no está en la URL: vive en history.state. Hay dos momentos para leerlo y confundirlos es un clásico.
import {Location} from '@angular/common';
export class DetalleTareaComponent {
private readonly router = inject(Router);
private readonly location = inject(Location);
constructor() {
// A) DURANTE la navegación (el constructor se ejecuta en la fase de activación):
const enCurso = this.router.getCurrentNavigation();
const mensaje = enCurso?.extras.state?.['mensaje'] as string | undefined;
// B) DESPUÉS de la navegación, o al recargar la página: history.state.
// Location.getState() ya filtra las claves internas del router (navigationId).
const estadoPersistido = this.location.getState() as {mensaje?: string} | null;
if (mensaje ?? estadoPersistido?.mensaje) {
this.avisos.mostrar(mensaje ?? estadoPersistido!.mensaje!);
}
}
}state
Debe ser serializable (el navegador lo clona estructuradamente): nada de clases con métodos, funciones ni instancias de entidades del ORM. Y tiene un límite de tamaño por entrada del historial. No es un canal para pasar objetos de dominio completos: pasa el identificador y vuelve a pedir los datos, o usa un servicio con señales. 6.7 Guards funcionales
Un guard es una función que el router llama en un punto concreto del ciclo de navegación y cuyo valor de retorno decide si la navegación continúa. Desde Angular 14.2 se escriben como funciones; los guards basados en clases están deprecados desde la 15.2.
type MaybeAsync<T> = T | Observable<T> | Promise<T>;
type GuardResult = boolean | UrlTree | RedirectCommand; // RedirectCommand: v18+
type CanActivateFn = (ruta: ActivatedRouteSnapshot, estado: RouterStateSnapshot)
=> MaybeAsync<GuardResult>;
type CanActivateChildFn = (hija: ActivatedRouteSnapshot, estado: RouterStateSnapshot)
=> MaybeAsync<GuardResult>;
type CanDeactivateFn<T> = (componente: T, rutaActual: ActivatedRouteSnapshot,
estadoActual: RouterStateSnapshot, estadoSiguiente: RouterStateSnapshot)
=> MaybeAsync<GuardResult>;
type CanMatchFn = (ruta: Route, segmentos: UrlSegment[]) => MaybeAsync<GuardResult>;
type ResolveFn<T> = (ruta: ActivatedRouteSnapshot, estado: RouterStateSnapshot) => MaybeAsync<T>;| Retorno | Significado |
|---|---|
true | Adelante. |
false | Se cancela (NavigationCancel). El usuario se queda donde estaba, sin ninguna pista de por qué. |
UrlTree | Se cancela y se lanza inmediatamente una navegación a ese árbol. Es la forma correcta de redirigir. |
Observable / Promise | El router espera el primer valor emitido; el observable debe emitir y, preferiblemente, completar. |
RedirectCommand | Redirección con NavigationBehaviorOptions (por ejemplo replaceUrl). API reciente: comprueba tu versión. |
1. Tree shaking. Una clase con providedIn: 'root' referenciada en la configuración de rutas entra en el bundle inicial aunque la ruta sea diferida. Una función se importa y se elimina con más facilidad.
2. Composición. Con funciones puedes escribir una fábrica: rolGuard('admin') devuelve un CanActivateFn. Con clases hacía falta una clase por rol o leer el rol de route.data, que es indirecto y frágil.
3. Menos ceremonia. inject() funciona dentro del guard porque el router lo ejecuta en un contexto de inyección; no hace falta constructor, decorador ni registro en providers.
4. Pruebas. Se prueban llamando a la función dentro de TestBed.runInInjectionContext().
Para migrar sin reescribir todo existen los adaptadores mapToCanActivate([MiGuard]), mapToCanMatch, mapToCanDeactivate y mapToResolve.
6.7.1 Autenticación con redirección y returnUrl
export const authGuard: CanActivateFn = () => {
const auth = inject(AuthService);
// Devolver false deja al usuario mirando la
// pantalla anterior sin explicación, y si venía
// de un enlace externo, mirando una app vacía.
return auth.estaAutenticado();
};
// Variante igual de mala: navegar Y devolver false.
// Se lanzan DOS navegaciones que compiten entre sí.
export const otro: CanActivateFn = () => {
inject(Router).navigate(['/login']);
return false;
}; export const authGuard: CanActivateFn = (_ruta, estado) => {
const auth = inject(AuthService);
const router = inject(Router);
if (auth.estaAutenticado()) return true;
// Devolver un UrlTree: el router lo trata como una
// única redirección atómica. Guardamos la URL de
// destino para volver tras el login.
return router.createUrlTree(['/login'], {
queryParams: {returnUrl: estado.url},
});
}; export class LoginComponent {
// Con withComponentInputBinding, el query param llega como input.
readonly returnUrl = input<string>('/');
private readonly router = inject(Router);
async entrar(credenciales: Credenciales): Promise<void> {
await this.auth.iniciarSesion(credenciales);
// SEGURIDAD: nunca navegues a ciegas a una URL que viene de la query string.
// Un atacante podría enviar ?returnUrl=https://malicioso.example y convertir
// tu login en un redirector abierto (open redirect).
const destino = this.esInterna(this.returnUrl()) ? this.returnUrl() : '/';
// replaceUrl: el botón atrás no debe volver al formulario de login.
await this.router.navigateByUrl(destino, {replaceUrl: true});
}
private esInterna(url: string): boolean {
// Debe empezar por una sola barra: descarta '//otro.dominio' y 'http://...'
return url.startsWith('/') && !url.startsWith('//');
}
}6.7.2 Guard de roles como fábrica, y canMatch frente a canActivate
// Fábrica: devuelve un guard configurado. Sirve para canActivate y para canMatch,
// porque ninguno de los dos usa realmente los argumentos en este caso.
export function rolGuard(...rolesRequeridos: Rol[]): CanActivateFn & CanMatchFn {
return () => {
const usuario = inject(SesionStore).usuario();
if (!usuario) return inject(Router).createUrlTree(['/login']);
const autorizado = rolesRequeridos.some((r) => usuario.roles.includes(r));
// Para canActivate, un UrlTree a /sin-permiso informa al usuario.
// Para canMatch, devolver false hace que el router pruebe la ruta siguiente.
return autorizado ? true : inject(Router).createUrlTree(['/sin-permiso']);
};
}// canMatch permite algo que canActivate no puede: rutas alternativas.
// El router prueba en orden; la primera cuyo canMatch devuelva true, gana.
export const routes: Routes = [
{
path: 'panel',
canMatch: [() => inject(SesionStore).esAdmin()],
loadComponent: () => import('./panel/panel-admin.component'),
},
{
path: 'panel', // misma URL, otro componente y otro chunk
loadComponent: () => import('./panel/panel-basico.component'),
},
];canMatch | canActivate | |
|---|---|---|
| Fase | Reconocimiento (antes de conocer la ruta definitiva) | Fase de guards (la ruta ya está decidida) |
Si devuelve false | El router sigue probando las rutas siguientes | La navegación se cancela |
Chunk de loadChildren | No se descarga | Ya se ha descargado |
Chunk de loadComponent | No se descarga | No se descarga (se carga tras los guards) |
| Recibe | Route y UrlSegment[]: sin parámetros resueltos | El ActivatedRouteSnapshot completo: parámetros y data |
| Úsalo para | Feature flags, rutas por rol o por plan de suscripción, tests A/B | Autorizar el acceso, comprobar precondiciones que dependen del parámetro |
canMatch ni canActivate son seguridad
Ocultar un chunk no protege nada: cualquiera puede leer el manifest de la aplicación, descargar el fichero y leer su contenido, o simplemente cambiar el valor de la señal de sesión desde la consola. Los guards son experiencia de usuario. La autorización real se comprueba en cada petición del backend (capítulo 12). 6.7.3 canDeactivate: cambios sin guardar
// Contrato mínimo que debe cumplir el componente protegido.
export interface PuedeTenerCambios {
readonly tieneCambiosSinGuardar: () => boolean; // señal o método
}
export const cambiosSinGuardarGuard: CanDeactivateFn<PuedeTenerCambios> = (
componente, _rutaActual, _estadoActual, estadoSiguiente,
) => {
if (!componente.tieneCambiosSinGuardar()) return true;
// Si el destino es la propia pantalla de login (sesión caducada), no tiene
// sentido preguntar: el usuario no puede guardar nada de todas formas.
if (estadoSiguiente.url.startsWith('/login')) return true;
// confirm() bloquea el hilo y no se puede estilizar; en producción se usa
// un diálogo propio que devuelve una promesa u observable.
return inject(DialogoService)
.confirmar('Tienes cambios sin guardar. ¿Salir y descartarlos?')
.pipe(take(1)); // el router toma el primer valor: cerramos el flujo explícitamente
};canDeactivate no protege del cierre de pestaña
El guard solo intercepta navegaciones internas. Para cerrar la pestaña, recargar o escribir otra dirección hace falta además el evento nativo beforeunload, que solo permite mostrar el mensaje genérico del navegador. Y ojo con el botón atrás: el router ya ha modificado el historial cuando el guard rechaza, por lo que necesita restaurarlo; ese ajuste se controla con canceledNavigationResolution en withRouterConfig. 6.8 Resolvers: cuándo merecen la pena y cuándo no
Un resolver obtiene datos antes de activar la ruta. Su gran ventaja es que el componente arranca con los datos ya disponibles: sin estado «cargando», sin plantilla defensiva, sin @if (dato). Su gran inconveniente es exactamente el mismo hecho: la navegación no ocurre hasta que la petición termina.
export const tareaResolver: ResolveFn<Tarea> = (ruta) => {
const api = inject(TareasApi);
const router = inject(Router);
return api.obtener(ruta.paramMap.get('id')!).pipe(
catchError((error: unknown) => {
// Si no gestionas el error, la navegación termina en NavigationError
// y el usuario se queda en la pantalla anterior sin ninguna explicación.
if (error instanceof HttpErrorResponse && error.status === 404) {
// Redirigir: cancela esta navegación y lanza otra.
router.navigate(['/tareas'], {state: {aviso: 'Esa tarea ya no existe'}});
} else {
inject(NotificadorErrores).registrar(error);
}
// EMPTY completa sin emitir: el router aborta la navegación en silencio.
return EMPTY;
}),
);
};El router solo toma el primer valor. Internamente aplica el equivalente a take(1). Un resolver que devuelve un observable de larga vida (un BehaviorSubject de una caché, por ejemplo) entregará el valor actual y nada más: los cambios posteriores no llegarán a route.data.
Los resolvers de una misma ruta se ejecutan en paralelo, pero la navegación espera al más lento. Cinco resolvers de 300 ms son 300 ms; uno de 3 s son 3 s de pantalla congelada.
6.8.1 Resolver frente a cargar en el componente
Usa un resolver cuando…
- El dato es imprescindible para decidir si la ruta existe (un 404 debe redirigir, no pintar una pantalla vacía).
- El título de la página depende del dato y quieres que sea correcto desde el primer instante (SSR, previsualizaciones al compartir el enlace).
- La petición es rápida y predecible (menos de ~200 ms) y evitar el parpadeo de carga mejora la percepción.
- Varios componentes hermanos de la misma ruta necesitan el mismo dato y no quieres pedirlo dos veces.
Carga en el componente cuando…
- La petición puede ser lenta o depende de una red móvil: es mejor navegar ya y mostrar un esqueleto.
- Hay varios bloques independientes: cada uno puede aparecer cuando esté listo en lugar de esperar todos.
- El dato debe poder recargarse sin navegar (
reload()). - Quieres control fino sobre errores y reintentos dentro de la pantalla, sin cancelar la navegación.
@Component({
template: `
@if (tarea.isLoading()) { <app-esqueleto-detalle /> } <!-- ya se ha navegado -->
@else if (tarea.error()) { <app-error (reintentar)="tarea.reload()" /> }
@else if (tarea.hasValue()) { <app-ficha-tarea [tarea]="tarea.value()" /> }
`,
})
export class DetalleTareaComponent {
readonly id = input.required<string>();
private readonly api = inject(TareasApi);
// La navegación es instantánea; el dato llega después y el usuario ve progreso.
readonly tarea = rxResource({
params: () => this.id(),
stream: ({params}) => this.api.obtener(params),
});
}6.9 Lazy loading y rendimiento de la navegación
El objetivo es que el bundle inicial contenga solo lo necesario para pintar la primera pantalla. Todo lo demás debe viajar en chunks que se descargan cuando se necesitan. La frontera natural es la ruta.
import {Routes} from '@angular/router';
// Un fichero de rutas por funcionalidad, exportado como constante o por defecto.
export const TAREAS_ROUTES: Routes = [
{
path: '',
// Layout propio de la funcionalidad, con su router-outlet.
loadComponent: () => import('./tareas-layout.component').then((m) => m.TareasLayoutComponent),
// Estado y servicios que solo viven mientras el usuario esté en /tareas.
providers: [TareasStore],
children: [
{path: '', loadComponent: () => import('./lista.component')}, // export default
{path: 'nueva', loadComponent: () => import('./crear.component')},
{path: ':id', loadComponent: () => import('./detalle.component')},
{path: ':id/editar', loadComponent: () => import('./editar.component'),
canDeactivate: [cambiosSinGuardarGuard]},
],
},
];
// Y en el fichero del componente:
// export default class ListaTareasComponent { ... }
// Angular admite export default en loadComponent y loadChildren, lo que
// ahorra el .then(m => m.X) y evita erratas en el nombre de la clase.import() debe ser estático y literal
El empaquetador analiza el código en tiempo de compilación. import('./' + nombre + '.component')
no genera ningún chunk útil: o falla, o arrastra todo el directorio. La ruta del import() tiene que ser una cadena literal. 6.9.1 Estrategias de precarga
Sin precarga, el primer clic en una sección diferida paga la descarga del chunk (y en una red lenta, eso se nota). Con PreloadAllModules, Angular descarga todos los chunks diferidos en cuanto la aplicación arranca y el hilo está libre. Es un buen punto de partida, pero en una aplicación grande significa descargar megabytes que el usuario quizá no visite.
import {Injectable} from '@angular/core';
import {PreloadingStrategy, Route} from '@angular/router';
import {EMPTY, Observable, timer, switchMap} from 'rxjs';
@Injectable({providedIn: 'root'})
export class PrecargaSelectivaStrategy implements PreloadingStrategy {
// 'load' es la función que descarga el chunk. Si no la llamas, no se descarga.
preload(ruta: Route, load: () => Observable<unknown>): Observable<unknown> {
// 1) Decisión declarativa desde la propia configuración de rutas.
if (ruta.data?.['preload'] !== true) return EMPTY;
// 2) Respeto por la conexión del usuario. La Network Information API NO está
// disponible en todos los navegadores (falta en Safari y Firefox), así que
// se comprueba con optional chaining y se asume "buena" si no hay dato.
const conexion = (navigator as Navigator & {connection?: NetworkInformationLike}).connection;
if (conexion?.saveData) return EMPTY; // modo ahorro de datos
if (/(^|-)(2g|slow-2g)$/.test(conexion?.effectiveType ?? '')) return EMPTY;
// 3) Esperar un poco para no competir con las peticiones críticas del arranque.
return timer(2000).pipe(switchMap(() => load()));
}
}
interface NetworkInformationLike { saveData?: boolean; effectiveType?: string; }
// provideRouter(routes, withPreloading(PrecargaSelectivaStrategy))
// y en la ruta: {path: 'informes', data: {preload: true}, loadChildren: ...}IntersectionObserver sobre los enlaces visibles o con mouseenter/focus sobre el enlace, llamando a mano al cargador. En plantillas, @defer (on hover) y @defer (on viewport) (capítulo 5) aplican la misma idea a nivel de bloque, y se combinan bien con el lazy loading de rutas. 6.9.2 Comprobar que los chunks existen de verdad
# 1) La tabla de salida del build ya dice qué hay en el bundle inicial
# y qué son chunks diferidos ("Lazy chunk files").
ng build --configuration production
# 2) Análisis detallado: quién ocupa qué dentro de cada chunk.
ng build --configuration production --stats-json
npx esbuild-visualizer --metadata dist/<app>/stats.json --open
# 3) Verificación empírica, la única que no miente: abre la pestaña Red de las
# DevTools, filtra por JS, navega a la sección y comprueba que aparece un
# fichero nuevo. Si aparece en la carga inicial, tu lazy loading no funciona.{
"budgets": [
{ "type": "initial", "maximumWarning": "500kB", "maximumError": "800kB" },
{ "type": "anyComponentStyle", "maximumWarning": "4kB", "maximumError": "8kB" }
]
}Los presupuestos son la única forma realista de que el bundle inicial no crezca mes a mes: convierten una regresión de rendimiento en un fallo de integración continua. Las causas más habituales de que un chunk no se separe son importar una clase de la funcionalidad diferida desde un fichero del bundle inicial (por ejemplo, un tipo importado como valor, o una constante compartida) y referenciar un guard de clase en la configuración raíz.
6.10 Rutas anidadas y outlets con nombre
Cada router-outlet de una plantilla es un punto de anclaje para las rutas hijas del nivel en el que está. Así se construyen los layouts: la barra lateral no se repinta al cambiar de sección porque pertenece a la ruta padre.
<div class="admin">
<nav>
<a routerLink="usuarios" routerLinkActive="activo">Usuarios</a>
<a routerLink="ajustes" routerLinkActive="activo">Ajustes</a>
</nav>
<!-- Outlet principal: aquí se pintan las rutas hijas sin 'outlet' -->
<main><router-outlet (activate)="alActivar($event)" (deactivate)="alDesactivar($event)" /></main>
<!-- Outlet auxiliar: panel lateral independiente de la ruta principal.
Se rellena solo si la URL contiene el grupo (lateral:...) -->
<aside><router-outlet name="lateral" /></aside>
</div>export const ADMIN_ROUTES: Routes = [
{
path: '',
component: AdminLayoutComponent,
children: [
{path: '', pathMatch: 'full', redirectTo: 'usuarios'},
{path: 'usuarios', loadComponent: () => import('./usuarios.component')},
{path: 'ajustes', loadComponent: () => import('./ajustes.component')},
// Rutas del outlet auxiliar: mismo nivel, otra zona de la pantalla.
{path: 'detalle/:id', outlet: 'lateral',
loadComponent: () => import('./usuario-panel.component')},
{path: 'ayuda', outlet: 'lateral',
loadComponent: () => import('./ayuda-panel.component')},
],
},
];private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute);
abrirPanel(id: string): void {
// La sintaxis {outlets: {...}} modifica SOLO el outlet indicado.
// URL resultante: /admin/usuarios(lateral:detalle/42)
this.router.navigate([{outlets: {lateral: ['detalle', id]}}], {relativeTo: this.route.parent});
}
cerrarPanel(): void {
// null vacía el outlet y limpia el grupo de la URL.
this.router.navigate([{outlets: {lateral: null}}], {relativeTo: this.route.parent});
}/admin/usuarios(lateral:detalle/42)) es fea, difícil de construir a mano y poco habitual, y los enlaces relativos hacia y desde un outlet auxiliar son una fuente constante de confusión. Merecen la pena cuando el estado del panel debe ser compartible y sobrevivir a una recarga (un modal profundo, un visor de detalle). Para un diálogo efímero, un servicio de diálogos es más simple y más barato de mantener. 6.10.1 Rutas sin componente: agrupar sin ensuciar el DOM
{
// Sin 'component' ni 'loadComponent': no crea nada en el DOM.
// Sirve para aplicar de una vez guards, providers y data a un grupo de rutas.
path: 'facturacion',
canActivate: [authGuard, rolGuard('finanzas')],
resolve: {empresa: empresaResolver},
providers: [FacturacionApi, {provide: MONEDA, useValue: 'EUR'}],
data: {seccion: 'facturacion'},
children: [
{path: 'facturas', loadComponent: () => import('./facturas.component')},
{path: 'recibos', loadComponent: () => import('./recibos.component')},
],
}params y data
Con la estrategia por defecto ('emptyOnly'), una ruta hija hereda params y data solo de padres sin componente o con path vacío. Por eso el ejemplo anterior funciona: empresa y seccion llegan a las hijas. Si el padre tiene componente y path no vacío y necesitas esa herencia, activa withRouterConfig({paramsInheritanceStrategy: 'always'}), o lee explícitamente route.parent.data. 6.11 HttpClient a fondo
HttpClient no es un envoltorio de fetch con azúcar. Aporta cuatro cosas que justifican usarlo en lugar de la API nativa: devuelve observables perezosos y cancelables (al cancelar la suscripción se aborta la petición real), una cadena de interceptores inyectable, tipado de la respuesta y, con ello, integración natural con el resto del framework (SSR, TransferState, tests con HttpTestingController).
Feature de provideHttpClient | Qué hace y cuándo la quieres |
|---|---|
withInterceptors([...]) | Registra interceptores funcionales en el orden dado. Es la forma recomendada. |
withFetch() | Usa la API fetch en lugar de XMLHttpRequest. Recomendada, y prácticamente obligatoria con SSR. Contrapartida: no informa del progreso de subida, solo del de descarga. |
withXsrfConfiguration({cookieName, headerName}) | Cambia los nombres por defecto (XSRF-TOKEN / X-XSRF-TOKEN) para encajar con tu backend. withNoXsrfProtection() la desactiva. |
withInterceptorsFromDi() | Compatibilidad con los interceptores de clase registrados en HTTP_INTERCEPTORS. Solo para código heredado: el orden relativo respecto a los funcionales no está garantizado, así que no mezcles ambos estilos. |
withRequestsMadeViaParent() | Cuando llamas a provideHttpClient() en el injector de una ruta o de un componente, hace que las peticiones sigan pasando por la cadena del injector padre en vez de crear una cadena aislada. Sirve para añadir un interceptor local sin perder los globales. |
withJsonpSupport() | Habilita http.jsonp(). Solo para APIs antiguas sin CORS. |
6.11.1 Peticiones tipadas, HttpParams y HttpHeaders
HttpParams y HttpHeaders son inmutables: cada método devuelve una instancia nueva y deja intacta la original. Esa decisión de diseño es correcta (un interceptor no puede modificar por sorpresa la petición de otro), pero produce el error más repetido de todo el capítulo.
buscar(filtro: Filtro) {
const params = new HttpParams();
// set() DEVUELVE una copia nueva; no muta nada.
// Estas tres líneas se tiran a la basura y la
// petición sale sin ningún parámetro.
params.set('q', filtro.texto);
params.set('pagina', String(filtro.pagina));
params.set('estado', filtro.estado);
return this.http.get<TareaDto[]>('/api/tareas', {params});
} buscar(filtro: Filtro) {
// Opción A: construir de una vez desde un objeto.
// Los valores admiten string, number, boolean y arrays.
const params = new HttpParams({
fromObject: {q: filtro.texto, pagina: filtro.pagina, estado: filtro.estado},
});
// Opción B: reasignar en cadena si hay condicionales.
let p = new HttpParams().set('pagina', filtro.pagina);
if (filtro.texto) p = p.set('q', filtro.texto);
return this.http.get<TareaDto[]>('/api/tareas', {params});
} HttpParams que ahorran horas
El objeto plano también vale. {params: {pagina: 2}} funciona y es más corto; usa HttpParams cuando necesites append (varios valores para la misma clave) o control del codificador.
Los valores undefined no se omiten solos. Filtra antes de construir, o acabarás enviando ?estado=undefined, que el backend interpretará como el texto «undefined».
La codificación por defecto no escapa +, = ni ; igual que encodeURIComponent, por compatibilidad histórica. Si envías cadenas con + (típico en fechas o en tokens), pasa un HttpParameterCodec propio.
@Injectable({providedIn: 'root'})
export class TareasApi {
private readonly http = inject(HttpClient);
private readonly base = '/api/tareas';
// Por defecto observe: 'body' → el observable emite el cuerpo ya deserializado.
listar(): Observable<TareaDto[]> { return this.http.get<TareaDto[]>(this.base); }
// observe: 'response' → HttpResponse<T>: acceso a status, headers y body.
// Imprescindible para paginación por cabeceras (X-Total-Count) o para leer ETag.
listarPaginado(pagina: number): Observable<{items: TareaDto[]; total: number}> {
return this.http.get<TareaDto[]>(this.base, {params: {pagina}, observe: 'response'}).pipe(
map((resp) => ({
items: resp.body ?? [],
total: Number(resp.headers.get('X-Total-Count') ?? 0),
})),
);
}
crear(dto: CrearTareaDto): Observable<TareaDto> {
return this.http.post<TareaDto>(this.base, dto, {
headers: new HttpHeaders({'Idempotency-Key': crypto.randomUUID()}),
});
}
// PATCH parcial. DELETE suele responder 204 sin cuerpo: el tipo correcto es void.
actualizar(id: string, cambios: Partial<CrearTareaDto>): Observable<TareaDto> {
return this.http.patch<TareaDto>(`${this.base}/${id}`, cambios);
}
eliminar(id: string): Observable<void> { return this.http.delete<void>(`${this.base}/${id}`); }
// responseType cambia cómo se interpreta la respuesta: 'json' (defecto),
// 'text', 'blob' o 'arraybuffer'. Un CSV o un PDF necesitan 'blob'.
exportarCsv(): Observable<Blob> {
return this.http.get(`${this.base}/export`, {responseType: 'blob'});
}
}get<TareaDto[]>() no valida nada en tiempo de ejecución: se limita a decirle al compilador «confía en mí». Si el backend cambia un campo, TypeScript seguirá compilando y el error aparecerá tres capas más adentro. En los límites del sistema, valida de verdad (Zod o Valibot) o al menos aísla la conversión en un mapeador, como se ve en 6.13. 6.11.2 Subida de ficheros con barra de progreso
import {HttpEvent, HttpEventType} from '@angular/common/http';
export type EstadoSubida =
| {tipo: 'progreso'; porcentaje: number}
| {tipo: 'completada'; fichero: FicheroDto};
subir(fichero: File): Observable<EstadoSubida> {
const cuerpo = new FormData();
cuerpo.append('fichero', fichero, fichero.name);
// NO pongas Content-Type a mano: el navegador debe añadir el boundary del
// multipart. Si lo fijas tú, el backend no sabrá dónde separa cada parte.
return this.http.post<FicheroDto>('/api/ficheros', cuerpo, {
reportProgress: true, // pide los eventos de progreso
observe: 'events', // el observable emite TODO el ciclo, no solo el cuerpo
}).pipe(
map((evento: HttpEvent<FicheroDto>): EstadoSubida | null => {
switch (evento.type) {
// total puede ser undefined si el servidor no informa del tamaño.
case HttpEventType.UploadProgress:
return {tipo: 'progreso',
porcentaje: evento.total ? Math.round((100 * evento.loaded) / evento.total) : 0};
case HttpEventType.Response: return {tipo: 'completada', fichero: evento.body!};
default: return null; // Sent, ResponseHeader, DownloadProgress, User…
}
}),
filter((e): e is EstadoSubida => e !== null),
);
}withFetch() y el progreso de subida
El backend de fetch no emite eventos UploadProgress: la API nativa no los expone. Si necesitas una barra de progreso de subida fiable, no actives withFetch() (o usa XMLHttpRequest directamente para ese caso concreto). El progreso de descarga sí funciona en ambos backends. Es una asimetría real que sorprende a mucha gente al migrar. 6.11.3 HttpErrorResponse: distinguir la red del servidor
export type ErrorDominio =
| {clase: 'sin-conexion'} // no hubo respuesta
| {clase: 'autenticacion'} | {clase: 'permisos'} // 401 · 403
| {clase: 'no-encontrado'} // 404
| {clase: 'validacion'; campos: Record<string, string[]>} // 422 / 400
| {clase: 'conflicto'; mensaje: string} // 409
| {clase: 'servidor'; mensaje: string}; // 5xx y el resto
export function aErrorDominio(error: unknown): ErrorDominio {
if (!(error instanceof HttpErrorResponse)) return {clase: 'servidor', mensaje: 'Error inesperado'};
// status 0 = la petición NUNCA obtuvo respuesta: red caída, DNS, CORS
// bloqueado, petición abortada o certificado rechazado. El navegador oculta
// el motivo real por seguridad, así que no intentes distinguirlo desde el JS.
if (error.status === 0) return {clase: 'sin-conexion'};
// A partir de aquí SÍ hubo respuesta del servidor y error.error es su cuerpo
// (ya deserializado si era JSON; una cadena si era texto).
switch (error.status) {
case 401: return {clase: 'autenticacion'};
case 403: return {clase: 'permisos'};
case 404: return {clase: 'no-encontrado'};
case 409: return {clase: 'conflicto', mensaje: leerMensaje(error)};
case 400:
case 422: return {clase: 'validacion', campos: error.error?.errors ?? {}};
default: return {clase: 'servidor', mensaje: leerMensaje(error)};
}
}
function leerMensaje(error: HttpErrorResponse): string {
// Formato problem+details (RFC 9457) o el estándar de NestJS: {message}.
return error.error?.detail ?? error.error?.message ?? error.statusText ?? 'Error del servidor';
}El valor de este mapeo es arquitectónico: a partir de aquí, ningún componente vuelve a mirar códigos HTTP. La interfaz reacciona a un tipo de dominio cerrado, exhaustivo y comprobable por el compilador, y el día que se cambie de transporte (GraphQL, gRPC, WebSocket) solo hay un fichero que tocar.
6.11.4 httpResource(): GET reactivos con señales
httpResource() se introdujo en Angular 19.2 como API experimental, junto con resource() y rxResource(). Los nombres de sus opciones han cambiado entre versiones menores. Comprueba la documentación de la versión exacta que tengas instalada antes de adoptarlo en producción, y no lo uses todavía como base de una librería compartida. import {httpResource} from '@angular/common/http';
// Plantilla: @if (tareas.isLoading()) { esqueleto } @else if (tareas.error())
// { error + botón que llama a tareas.reload() } @else { @for sobre value() }
export class ListaTareasComponent {
readonly texto = signal('');
readonly pagina = signal(1);
// La función reactiva se reevalúa cuando cambia CUALQUIER señal que lee.
// Cada cambio lanza un GET nuevo y ABORTA el anterior automáticamente.
readonly tareas = httpResource<Tarea[]>(
() => ({url: '/api/tareas', params: {q: this.texto(), pagina: this.pagina()}}),
{
defaultValue: [],
// parse es el punto natural para validar Y para mapear DTO → dominio.
parse: (crudo) => TareaSchema.array().parse(crudo).map(desdeDto),
},
);
// Expone además: status(), headers(), statusCode(), progress(), hasValue(), reload()
// Variantes para respuestas no JSON: httpResource.text(), .blob(), .arrayBuffer()
}httpResource() | resource() / rxResource() | HttpClient + RxJS | |
|---|---|---|---|
| Transporte | Siempre HttpClient (pasa por los interceptores) | Cualquiera: fetch, WebSocket, IndexedDB, SDK de terceros | HttpClient |
| Estado de carga y error | Incluido en señales | Incluido en señales | Lo escribes tú |
| Escrituras (POST/PUT) | No es su caso de uso: está pensado para lecturas | No | Sí, es lo natural |
| Composición avanzada | Limitada | Limitada | Total: debounceTime, retry, combineLatest… |
| Cuándo usarlo | Un GET que depende de señales de la interfaz (filtros, paginación) | Igual, pero con un origen que no es HTTP | Mutaciones, flujos complejos, servicios de API reutilizables |
6.12 Interceptores funcionales
Un interceptor es una función que recibe la petición y un next, y devuelve un observable de eventos. Al llamar a next(req) cede el control al siguiente eslabón. Como puede transformar la petición antes y la respuesta después, la cadena funciona como una cebolla: se atraviesa hacia dentro en la ida y hacia fuera en la vuelta, en orden inverso.
provideHttpClient(withInterceptors([auth, idioma, tiempos, reintento, cache]))
http.get('/api/tareas')
│
▼ PETICIÓN: en el orden del array
┌───────┐ ┌────────┐ ┌─────────┐ ┌───────────┐ ┌───────┐ ┌─────────────┐
│ auth │──►│ idioma │──►│ tiempos │──►│ reintento │──►│ cache │──►│ HttpBackend │
└───────┘ └────────┘ └─────────┘ └───────────┘ └───────┘ │ fetch / XHR │
▲ ▲ ▲ ▲ ▲ └──────┬──────┘
│ │ │ │ │ │
└────────────┴────────────┴──────────────┴─────────────┴──────────────┘
RESPUESTA: en orden INVERSO
Consecuencias del orden:
▸ 'auth' es el primero: su cabecera la ven todos los de abajo.
▸ 'reintento' está DESPUÉS de 'auth': cada reintento vuelve a pasar por
el backend pero NO vuelve a ejecutar 'auth', así que reenvía el token
que ya llevaba la petición clonada.
▸ 'cache' es el último: si sirve desde caché, la petición nunca llega a
la red, pero sí ha pasado por los interceptores anteriores.
▸ 'tiempos' mide todo lo que hay por debajo de él, no la petición completa.import {HttpContextToken} from '@angular/common/http';
// Un HttpContextToken viaja con la petición sin ensuciar cabeceras ni URL.
// Es el mecanismo idiomático para las excepciones, mucho mejor que comprobar
// la URL con expresiones regulares dentro del interceptor.
export const SIN_AUTH = new HttpContextToken<boolean>(() => false);
export const SIN_CARGA = new HttpContextToken<boolean>(() => false);
export const CACHEABLE = new HttpContextToken<boolean>(() => false);
export const REINTENTADA = new HttpContextToken<boolean>(() => false);
// Uso desde un servicio:
// this.http.get('/api/publico', {context: new HttpContext().set(SIN_AUTH, true)});import {HttpInterceptorFn, HttpResponse} from '@angular/common/http';
import {inject, LOCALE_ID} from '@angular/core';
import {finalize, tap} from 'rxjs';
// 1) TOKEN DE AUTENTICACIÓN
export const authInterceptor: HttpInterceptorFn = (req, next) => {
// inject() debe llamarse de forma SÍNCRONA aquí: dentro de un callback
// posterior ya no hay contexto de inyección (error NG0203).
const token = inject(SesionStore).accessToken();
// SEGURIDAD: no envíes nunca el token a un dominio que no sea el tuyo.
const esPropia = req.url.startsWith('/') || req.url.startsWith(API_BASE);
if (!token || !esPropia || req.context.get(SIN_AUTH)) return next(req);
// clone es obligatorio: la petición es inmutable.
return next(req.clone({setHeaders: {Authorization: `Bearer ${token}`}}));
};
// 2) CABECERA DE IDIOMA
export const idiomaInterceptor: HttpInterceptorFn = (req, next) => {
const idioma = inject(LOCALE_ID); // 'es-ES' con la configuración del CLI
return next(req.clone({setHeaders: {'Accept-Language': idioma}}));
};
// 3) LOGGING DE TIEMPOS
export const tiemposInterceptor: HttpInterceptorFn = (req, next) => {
const t0 = performance.now();
let estado = 0;
return next(req).pipe(
tap({next: (e) => { if (e instanceof HttpResponse) estado = e.status; }}),
// finalize se ejecuta también si hay error o si se cancela la suscripción,
// así que la medición nunca se pierde.
finalize(() => {
const ms = performance.now() - t0;
if (ms > 1000) console.warn(`[http] LENTA ${req.method} ${req.urlWithParams} ${ms.toFixed(0)} ms`);
else console.debug(`[http] ${req.method} ${req.urlWithParams} ${estado} ${ms.toFixed(0)} ms`);
}),
);
};
// 4) INDICADOR GLOBAL DE CARGA
export const cargaInterceptor: HttpInterceptorFn = (req, next) => {
if (req.context.get(SIN_CARGA)) return next(req); // sondeos, telemetría, autocompletar
const indicador = inject(IndicadorCargaService);
indicador.comenzar();
return next(req).pipe(finalize(() => indicador.terminar()));
};@Injectable({providedIn: 'root'})
export class IndicadorCargaService {
// Un CONTADOR, no un booleano: con dos peticiones simultáneas, la primera
// que termina apagaría el indicador mientras la otra sigue en curso.
private readonly enCurso = signal(0);
readonly cargando = computed(() => this.enCurso() > 0);
comenzar(): void { this.enCurso.update((n) => n + 1); }
terminar(): void { this.enCurso.update((n) => Math.max(0, n - 1)); }
}// 5) REINTENTO CON RETROCESO EXPONENCIAL
const IDEMPOTENTES = new Set(['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE']);
export const reintentoInterceptor: HttpInterceptorFn = (req, next) => {
// Reintentar un POST puede duplicar un pedido. Solo métodos idempotentes
// (o POST con cabecera Idempotency-Key acordada con el backend).
if (!IDEMPOTENTES.has(req.method)) return next(req);
return next(req).pipe(
retry({
count: 3,
delay: (error, intento) => {
const reintentable = error instanceof HttpErrorResponse &&
(error.status === 0 || error.status === 429 || error.status >= 500);
if (!reintentable) return throwError(() => error); // 4xx: no insistas
// Retroceso exponencial + jitter, para no sincronizar a mil clientes
// contra un servidor que intenta levantarse (efecto manada atronadora).
const espera = Math.min(500 * 2 ** (intento - 1), 8000) + Math.random() * 250;
return timer(espera);
},
}),
);
};
// 6) CACHÉ DE GET
export const cacheInterceptor: HttpInterceptorFn = (req, next) => {
if (req.method !== 'GET' || !req.context.get(CACHEABLE)) return next(req);
const cache = inject(CacheHttpService);
const guardada = cache.obtener(req.urlWithParams);
// clone() evita entregar la MISMA instancia a dos consumidores que
// podrían mutar el cuerpo y contaminarse entre sí.
if (guardada) return of(guardada.clone());
return next(req).pipe(
tap((evento) => {
if (evento instanceof HttpResponse) cache.guardar(req.urlWithParams, evento);
}),
);
};
// 7) FECHAS ISO → Date
const ISO_8601 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;
function revivirFechas(valor: unknown): unknown {
if (typeof valor === 'string') return ISO_8601.test(valor) ? new Date(valor) : valor;
if (Array.isArray(valor)) return valor.map(revivirFechas);
if (valor !== null && typeof valor === 'object') {
return Object.fromEntries(
Object.entries(valor).map(([k, v]) => [k, revivirFechas(v)]),
);
}
return valor;
}
export const fechasInterceptor: HttpInterceptorFn = (req, next) =>
next(req).pipe(
map((evento) => evento instanceof HttpResponse
? evento.clone({body: revivirFechas(evento.body)})
: evento),
);TareaDto.creada dice string pero en tiempo de ejecución es un Date. En un proyecto nuevo prefiero mapeadores explícitos por recurso (6.13): más código, cero magia. El interceptor es una solución razonable cuando heredas cientos de llamadas ya escritas. 6.12.1 Refresco de token en 401 sin condiciones de carrera
Es el interceptor que más veces se implementa mal. El escenario: el access token caduca mientras la pantalla lanza tres peticiones en paralelo. Las tres reciben 401. Una implementación ingenua dispara tres refrescos simultáneos; si el backend rota el refresh token en cada uso (lo correcto), dos de los tres fallarán y cerrarán la sesión del usuario sin motivo aparente.
t0 GET /tareas ─┐
GET /perfil ─┼─ las tres llevan el access token ya caducado
GET /avisos ─┘
│
▼ 401 · 401 · 401
t1 ┌──────────────────────────────────────────────────────────────┐
│ interceptor: ¿hay ya un refresco en curso EN EL SERVICIO? │
│ NO ─► lo inicia y lo guarda (una sola vez) │
│ SÍ ─► se engancha al MISMO observable (shareReplay 1) │
└──────────────────────────────────────────────────────────────┘
│ UNA sola POST /auth/refresh
▼
t2 nuevo access token ─► se guarda en la señal de sesión
│
▼
t3 se reintentan las TRES peticiones originales, cada una marcada
con el contexto REINTENTADA
│
▼
t4 si alguna vuelve a dar 401 ─► REINTENTADA ya es true ─► no se
refresca otra vez: se cierra la sesión. Aquí se corta el bucle.export const authInterceptor: HttpInterceptorFn = (req, next) => {
const auth = inject(AuthService);
return next(conToken(req, auth.accessToken())).pipe(
catchError((e: HttpErrorResponse) => {
if (e.status !== 401) return throwError(() => e);
// FALLO 1: cada petición lanza SU refresco. Con rotación
// de refresh token, todas menos una fallan.
// FALLO 2: la reintentada no está marcada; si vuelve a
// dar 401 se refresca otra vez → bucle infinito
// de peticiones hasta que el navegador se ahoga.
// FALLO 3: la propia llamada a /auth/refresh pasa por
// este interceptor y puede refrescarse a sí misma.
return auth.refrescar().pipe(
switchMap((t) => next(conToken(req, t))),
);
}),
);
}; export const authInterceptor: HttpInterceptorFn = (req, next) => {
const auth = inject(AuthService);
const token = auth.accessToken();
const peticion = req.context.get(SIN_AUTH) || !token
? req : conToken(req, token);
return next(peticion).pipe(
catchError((error: unknown) => {
const es401 = error instanceof HttpErrorResponse && error.status === 401;
// Corta el bucle: la propia /auth/refresh lleva SIN_AUTH,
// y una petición ya reintentada no vuelve a refrescar.
if (!es401 || req.context.get(SIN_AUTH) || req.context.get(REINTENTADA)) {
return throwError(() => error);
}
// refrescar() devuelve un flujo COMPARTIDO: las tres
// peticiones concurrentes se enganchan al mismo.
return auth.refrescar().pipe(
switchMap((nuevo) => next(
conToken(req.clone({context: req.context.set(REINTENTADA, true)}), nuevo),
)),
catchError((err) => { auth.cerrarSesion(); return throwError(() => err); }),
);
}),
);
};
function conToken(req: HttpRequest<unknown>, token: string) {
return req.clone({setHeaders: {Authorization: `Bearer ${token}`}});
} @Injectable({providedIn: 'root'})
export class AuthService {
private readonly http = inject(HttpClient);
readonly accessToken = signal<string | null>(null);
// El estado compartido vive en el SERVICIO, no en una variable de módulo.
// Motivo crítico: en SSR el módulo se comparte entre TODAS las peticiones
// del servidor, así que una variable de módulo filtraría el refresco (y el
// token) de un usuario a otro. El injector, en cambio, es por petición.
private refresco$: Observable<string> | null = null;
refrescar(): Observable<string> {
this.refresco$ ??= this.http
.post<{accessToken: string}>('/api/auth/refresh', {}, {
withCredentials: true, // el refresh token va en cookie httpOnly
context: new HttpContext().set(SIN_AUTH, true), // no interceptar esta llamada
})
.pipe(
map((r) => r.accessToken),
tap((t) => this.accessToken.set(t)),
// finalize ANTES de shareReplay: se ejecuta cuando el flujo compartido
// termina, y libera el hueco para un refresco futuro.
finalize(() => { this.refresco$ = null; }),
// refCount: false mantiene el refresco vivo aunque el primer
// suscriptor se desuscriba (por ejemplo, si el usuario navega).
shareReplay({bufferSize: 1, refCount: false}),
);
return this.refresco$;
}
cerrarSesion(): void {
this.accessToken.set(null);
this.refresco$ = null;
inject(Router).navigateByUrl('/login', {replaceUrl: true});
}
}6.13 Patrones de capa de datos
La regla estructural es sencilla: ningún componente inyecta HttpClient. Entre la interfaz y la red hay un servicio por recurso que traduce el lenguaje del backend al del dominio. Ese servicio es la costura que permite cambiar de API, versionarla o simularla en tests sin tocar la interfaz.
// DTO: la forma EXACTA que habla el backend. Fechas como cadenas, campos
// planos, nombres del servidor, enteros donde el dominio quiere enumerados.
export interface TareaDto {
id: string; title: string; done: boolean;
due_date: string | null; priority: 0 | 1 | 2; assignee_id: string | null;
}
// Modelo de dominio: la forma que quiere la interfaz. Tipos ricos y nombres
// del negocio, en el idioma del proyecto.
export interface Tarea {
id: string; titulo: string; completada: boolean;
vencimiento: Date | null; prioridad: 'baja' | 'media' | 'alta'; responsableId: string | null;
}
const PRIORIDADES = ['baja', 'media', 'alta'] as const;
// Los dos mapeadores son el único punto del frontend que conoce los nombres
// y los formatos del servidor. Son funciones puras: triviales de testear.
export function desdeDto(dto: TareaDto): Tarea {
return {id: dto.id, titulo: dto.title, completada: dto.done,
vencimiento: dto.due_date ? new Date(dto.due_date) : null,
prioridad: PRIORIDADES[dto.priority], responsableId: dto.assignee_id};
}
export function aDto(t: Omit<Tarea, 'id'>): Omit<TareaDto, 'id'> {
return {title: t.titulo, done: t.completada,
due_date: t.vencimiento?.toISOString() ?? null,
priority: PRIORIDADES.indexOf(t.prioridad) as 0 | 1 | 2, assignee_id: t.responsableId};
}Alguien objetará que esto es duplicar interfaces. No lo es: son dos contratos distintos con dos dueños distintos. El día que el backend renombre due_date a deadline, el cambio se resuelve en una línea del mapeador en lugar de en cuarenta plantillas. Y si el backend es también tuyo y comparte los tipos (monorepo, capítulo 9), sigue mereciendo la pena separar el modelo de vista del contrato de transporte.
@Injectable({providedIn: 'root'})
export class TareasApi {
private readonly http = inject(HttpClient);
// Peticiones en vuelo: dos componentes que piden lo mismo a la vez comparten
// UNA petición. Es deduplicación, no caché: la entrada se borra al terminar.
private readonly enVuelo = new Map<string, Observable<Tarea>>();
// Caché de lectura con invalidación explícita.
private readonly cache = new Map<string, Observable<Tarea[]>>();
obtener(id: string): Observable<Tarea> {
const existente = this.enVuelo.get(id);
if (existente) return existente;
const peticion = this.http.get<TareaDto>(`/api/tareas/${id}`).pipe(
map(desdeDto),
catchError((e: unknown) => throwError(() => aErrorDominio(e))),
finalize(() => this.enVuelo.delete(id)),
shareReplay({bufferSize: 1, refCount: true}),
);
this.enVuelo.set(id, peticion);
return peticion;
}
listar(filtro = ''): Observable<Tarea[]> {
if (!this.cache.has(filtro)) {
this.cache.set(filtro, this.http.get<TareaDto[]>('/api/tareas', {params: {q: filtro}}).pipe(
map((dtos) => dtos.map(desdeDto)),
// refCount: false mantiene el valor aunque nadie escuche: es una caché.
// Sin invalidarla nunca, el usuario vería datos rancios para siempre.
shareReplay({bufferSize: 1, refCount: false}),
));
}
return this.cache.get(filtro)!;
}
crear(tarea: Omit<Tarea, 'id'>): Observable<Tarea> {
return this.http.post<TareaDto>('/api/tareas', aDto(tarea)).pipe(
map(desdeDto),
// Toda escritura invalida las lecturas afectadas. Es la parte que se
// olvida siempre y la razón de los "no se actualiza hasta que recargo".
tap(() => this.invalidar()),
);
}
invalidar(): void { this.cache.clear(); }
}ngOnInit(): void {
// Suscripción anidada: no cancela nada. Si el usuario
// teclea cinco letras, salen cinco peticiones y las
// respuestas pueden llegar DESORDENADAS: la vista
// acaba mostrando el resultado de la búsqueda "ta"
// sobre la caja donde dice "tareas".
this.buscador.valueChanges.subscribe((texto) => {
this.api.listar(texto).subscribe((r) => (this.items = r));
});
// Y al destruirse el componente, ambas siguen vivas.
} // switchMap cancela la petición anterior (aborta el
// fetch de verdad) y garantiza el orden: solo llega la
// última. takeUntilDestroyed corta al destruir.
readonly items = toSignal(
this.buscador.valueChanges.pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap((texto) => this.api.listar(texto ?? '')),
takeUntilDestroyed(),
),
{initialValue: []},
); AsyncPipe, toSignal, takeUntilDestroyed() o un recurso de señales lo hacen por ti; un subscribe manual sin desuscripción, no. Con HttpClient, cancelar la suscripción aborta la petición HTTP real (AbortController con fetch, xhr.abort() con XHR), lo que libera también recursos del servidor. 6.14 Comunicación en tiempo real
import {webSocket, WebSocketSubject} from 'rxjs/webSocket';
import {Observable, retry, timer, share} from 'rxjs';
// ── WebSocket: bidireccional. webSocket() de RxJS abre la conexión al primer
// suscriptor y la cierra cuando no queda ninguno.
@Injectable({providedIn: 'root'})
export class NotificacionesSocket {
readonly conectado = signal(false);
private readonly socket: WebSocketSubject<MensajeServidor> = webSocket({
// El token no puede ir en cabeceras (el handshake no las admite desde JS):
// se usa un ticket de un solo uso pedido antes por HTTP, o una cookie.
url: `wss://api.example/ws?ticket=${obtenerTicket()}`,
openObserver: {next: () => this.conectado.set(true)},
closeObserver: {next: () => this.conectado.set(false)}});
// Reconexión con retroceso; share() para que N componentes usen UNA conexión.
readonly mensajes$ = this.socket.pipe(
retry({delay: (_e, n) => timer(Math.min(1000 * 2 ** (n - 1), 30_000))}),
share({resetOnRefCountZero: false}),
);
enviar(m: MensajeCliente): void { this.socket.next(m as never); }
}
// ── SSE: unidireccional servidor → cliente, sobre HTTP normal. Reconexión
// automática del navegador y reanudación con Last-Event-ID.
export function eventosServidor<T>(url: string): Observable<T> {
return new Observable<T>((observador) => {
// Limitación importante: EventSource NO admite cabeceras propias, así que
// no puedes enviar Authorization. Autentica con cookie o con un token en
// la query string (y asume que quedará en los logs del servidor).
const fuente = new EventSource(url, {withCredentials: true});
fuente.onmessage = (e) => observador.next(JSON.parse(e.data) as T);
// readyState CLOSED significa fallo definitivo; en otro caso el navegador
// ya está reintentando por su cuenta: no lo trates como error fatal.
fuente.onerror = () => { if (fuente.readyState === EventSource.CLOSED)
observador.error(new Error('SSE cerrado')); };
return () => fuente.close(); // limpieza al cancelar la suscripción
});
}| Criterio | Polling (HTTP repetido) | SSE (EventSource) | WebSocket |
|---|---|---|---|
| Dirección | Cliente pregunta | Servidor empuja | Bidireccional |
| Protocolo | HTTP | HTTP (respuesta abierta) | Actualización a ws:// |
| Latencia | Media = intervalo / 2 | Inmediata | Inmediata |
| Coste en servidor | Alto si el intervalo es corto y hay muchos clientes | Una conexión abierta por cliente | Una conexión abierta por cliente, con estado |
| Reconexión | Trivial (cada petición es nueva) | Automática, con Last-Event-ID | Manual: la implementas tú |
| Auth y cabeceras | Todo lo de HTTP, e interceptores de Angular | Sin cabeceras propias: cookie o ticket | Sin cabeceras: ticket o subprotocolo |
| Proxies y balanceadores | Sin problemas | Casi siempre bien; desactiva el buffering | Requiere configuración específica y sesiones pegajosas |
| Elígelo para | Datos que cambian cada minutos: informes, estado de un trabajo por lotes | Notificaciones, progreso, precios, feeds: el 80 % de los casos reales | Chat, edición colaborativa, juegos, telemetría de alta frecuencia |
Detección de cambios. Los eventos de EventSource y de WebSocket nativos llegan fuera de las APIs que Angular parchea, así que con zone.js podrían no disparar detección de cambios. Con provideZonelessChangeDetection() o escribiendo en una señal el problema desaparece; en una aplicación con zonas, envuelve la escritura en ngZone.run().
Los interceptores no se aplican. Ni WebSocket ni EventSource pasan por HttpClient: tu interceptor de token, de idioma o de reintentos no existe para ellos. Toda esa lógica hay que replicarla a mano.
6.15 Gestión de estado: cuándo basta un servicio con señales
Casi ninguna aplicación necesita una librería de estado el primer día, y casi ninguna decisión de arquitectura se arrepiente tanto como haber montado NgRx para tres formularios. El criterio no es el tamaño de la aplicación, sino la naturaleza del estado: cuánto se comparte, cuántos actores lo modifican y cuánto importa poder auditar cada cambio.
@Injectable({providedIn: 'root'})
export class TareasStore {
private readonly api = inject(TareasApi);
// Estado privado; lo público es de solo lectura.
private readonly _tareas = signal<Tarea[]>([]);
private readonly _filtro = signal<Estado>('todas');
readonly tareas = this._tareas.asReadonly();
readonly filtro = this._filtro.asReadonly();
// Derivados: se recalculan solos y se memorizan.
readonly visibles = computed(() => {
const f = this._filtro();
return f === 'todas' ? this._tareas()
: this._tareas().filter((t) => t.completada === (f === 'hechas'));
});
readonly pendientes = computed(() => this._tareas().filter((t) => !t.completada).length);
filtrar(f: Estado): void { this._filtro.set(f); }
async completar(id: string): Promise<void> {
const previo = this._tareas();
// Actualización optimista + rollback si falla.
this._tareas.update((ts) => ts.map((t) =>
t.id === id ? {...t, completada: true} : t));
try { await firstValueFrom(this.api.completar(id)); }
catch (e) { this._tareas.set(previo); throw e; }
}
} // 1) Acciones
export const tareasActions = createActionGroup({
source: 'Tareas',
events: {cargar: emptyProps(), 'cargar ok': props<{tareas: Tarea[]}>(),
'cargar error': props<{error: string}>(), completar: props<{id: string}>()},
});
// 2) Reducer
export const reducer = createReducer(estadoInicial,
on(tareasActions.cargarOk, (s, {tareas}) => ({...s, tareas, cargando: false})),
on(tareasActions.completar, (s, {id}) => ({...s,
tareas: s.tareas.map((t) => t.id === id ? {...t, completada: true} : t)})),
);
// 3) Selectores
export const selectVisibles = createSelector(
selectTareas, selectFiltro, (tareas, f) => /* … */ tareas);
// 4) Efecto
export const cargar$ = createEffect((acciones = inject(Actions), api = inject(TareasApi)) =>
acciones.pipe(ofType(tareasActions.cargar),
switchMap(() => api.listar().pipe(
map((tareas) => tareasActions.cargarOk({tareas})),
catchError((e) => of(tareasActions.cargarError({error: String(e)})))))),
{functional: true});
// 5) Registro: provideStore, provideEffects, provideStoreDevtools
// 6) En el componente: store.dispatch(...) y store.select(...) La comparación es honesta en las dos direcciones. La versión con señales tiene 30 líneas y se lee de arriba abajo. La de NgRx tiene el triple, reparte la lógica en cinco ficheros y obliga a aprender un vocabulario nuevo. A cambio, NgRx te da algo que la primera no: una traza completa de todo lo que ha pasado. En las DevTools de Redux ves cada acción, su carga útil, el estado antes y después, y puedes retroceder en el tiempo. Cuando un bug solo aparece en producción y solo en una cuenta concreta, ese registro vale su peso en oro.
| Criterio | Servicio con señales | NgRx SignalStore | NgRx Store clásico |
|---|---|---|---|
| Código para un caso simple | Mínimo | Bajo | Alto (acciones, reducer, efectos, selectores) |
| Curva de aprendizaje | Ya la conoces | Media: withState, withComputed, withMethods, withHooks | Alta: flujo unidireccional, inmutabilidad, efectos |
| DevTools y time travel | No (solo el depurador) | Parcial, con la extensión de DevTools | Sí, completo |
| Trazabilidad de quién cambió qué | Depende de tu disciplina | Buena si respetas los withMethods | Total: toda mutación es una acción con nombre |
| Colecciones y entidades | A mano | withEntities del paquete de entidades | @ngrx/entity |
| Coste de mantenimiento | Crece si el estado se comparte mucho y nadie pone orden | Bajo y predecible | Alto, pero muy uniforme entre equipos |
| Encaja cuando… | Estado por funcionalidad, equipos pequeños, la mayoría de los CRUD | Punto medio actual recomendable en Angular moderno | Estado global complejo, muchos equipos, auditoría o undo/redo como requisito |
6.16 Seguridad en la comunicación
6.16.1 CORS explicado desde el navegador
CORS no es una medida de seguridad del servidor: es una restricción que el navegador se impone a sí mismo para que el JavaScript de un origen no pueda leer respuestas de otro. El servidor puede seguir ejecutando la operación; lo que el navegador impide es que tu código vea el resultado.
Front: https://app.example API: https://api.example
─────────────────────────────────────────────────────────────────────────────────────
A) Petición SIMPLE (GET/HEAD/POST con content-type de formulario, sin
cabeceras propias): se envía SIEMPRE, y después se decide si puedes leerla.
JS ──► navegador ──────────────► API (la API ya ha hecho el trabajo)
◄────────────── 200 + Access-Control-Allow-Origin: …
¿ese origen coincide con el mío?
NO ─► el JS recibe un error de CORS y status 0. Nunca ves el cuerpo.
SÍ ─► el JS lee la respuesta.
B) Petición con PREFLIGHT (PUT/PATCH/DELETE, Content-Type: application/json,
o cabeceras propias como Authorization). El navegador PREGUNTA primero:
OPTIONS /tareas/42 ← lo envía el navegador
Origin: https://app.example solo, sin tu código
Access-Control-Request-Method: PATCH
Access-Control-Request-Headers: authorization,content-type
◄── 204 No Content
Access-Control-Allow-Origin: https://app.example ← NO puede ser '*' si
Access-Control-Allow-Methods: PATCH hay credenciales
Access-Control-Allow-Headers: authorization,content-type
Access-Control-Allow-Credentials: true ← para cookies
Access-Control-Expose-Headers: X-Total-Count ← para LEER cabeceras
Access-Control-Max-Age: 600 ← cachea el preflight
PATCH /tareas/42 ──► ahora sí viaja la petición real1. Allow-Origin: * con withCredentials: true. El navegador lo rechaza siempre: con credenciales, el origen debe ser explícito.
2. Puedes leer el cuerpo pero no una cabecera. Falta Access-Control-Expose-Headers; por defecto solo se exponen unas pocas cabeceras seguras.
3. Todo va lento. Falta Access-Control-Max-Age: cada petición paga su preflight.
4. En desarrollo funciona y en producción no. Porque en desarrollo usabas el proxy del CLI (proxy.conf.json), que hace las peticiones desde el mismo origen y elimina el CORS del problema. Es lo recomendable, pero no sustituye a configurar bien el servidor.
6.16.2 XSRF/CSRF y el soporte integrado de Angular
Un ataque CSRF explota que el navegador envía las cookies automáticamente: una página maliciosa envía un formulario a tu API y el navegador adjunta la sesión del usuario. La defensa habitual es el patrón double submit: un valor que el atacante no puede leer (por la política del mismo origen) debe viajar también en una cabecera.
provideHttpClient(
withFetch(),
// Angular lee la cookie indicada y copia su valor en la cabecera indicada,
// pero SOLO en peticiones mutantes (no GET ni HEAD) y SOLO a URLs relativas
// o del mismo origen. Nunca envía el token a otro dominio.
withXsrfConfiguration({cookieName: 'XSRF-TOKEN', headerName: 'X-XSRF-TOKEN'}),
)
// Requisitos para que funcione:
// 1. La cookie NO puede ser httpOnly: Angular necesita leerla desde JS.
// 2. El backend debe emitirla y validar que cabecera y cookie coinciden.
// 3. Si tu API está en OTRO dominio, el mecanismo integrado no se aplica:
// tendrás que enviar la cabecera a mano en un interceptor.6.16.3 Dónde guardar el token
| Ubicación | Riesgo ante XSS | Riesgo ante CSRF | Sobrevive a recargar | Veredicto |
|---|---|---|---|---|
| Variable en memoria (señal de un servicio) | Solo mientras la pestaña vive; no queda nada persistido | Ninguno (no se envía solo) | No | Mejor opción para el access token, con refresco en cookie httpOnly |
localStorage | Alto: cualquier XSS lo lee y lo exfiltra, y persiste entre sesiones | Ninguno | Sí | Cómodo y muy extendido; aceptable solo con tokens de vida muy corta |
sessionStorage | Alto, pero limitado a la pestaña | Ninguno | Sí, en esa pestaña | Igual que el anterior con menos exposición |
Cookie httpOnly + Secure + SameSite=Lax/Strict | Bajo: el JavaScript no puede leerla | Existe: necesita protección CSRF | Sí | Lo correcto para el refresh token, y para la sesión en un patrón BFF |
| BFF (el backend guarda la sesión, el front solo cookies) | El más bajo: no hay token en el navegador | Existe: CSRF más SameSite | Sí | El más seguro; exige un servidor propio delante |
localStorage, repetir una petición con curl quitando cabeceras o desactivar por completo tu JavaScript. Los guards, los campos deshabilitados y los botones ocultos son usabilidad. La autorización se comprueba en el servidor, en cada petición, sobre el token verificado criptográficamente, y contra el recurso concreto que se pide. Si un endpoint solo está protegido porque «esa pantalla no aparece en el menú», no está protegido. 6.17 Errores comunes y cómo solucionarlos
| Síntoma o error | Causa real | Solución |
|---|---|---|
NG04002: Cannot match any routes. URL Segment: 'x' | No hay ninguna ruta que empareje, o la que debía hacerlo está después de otra que consume el segmento | Revisar el orden del array, comprobar que el path no lleva barra inicial y añadir {path: '**'} al final |
| Bucle infinito de redirecciones al arrancar | Ruta vacía con redirectTo sin pathMatch: 'full' | Añadir pathMatch: 'full'; el modo desarrollo de Angular ya lo exige explícitamente |
La ruta hija con path: '' no se muestra | Se le puso pathMatch: 'full' teniendo hijas o quedando segmentos por consumir | Dejar el 'prefix' por defecto en rutas vacías con componente o hijas |
| La URL cambia pero la vista no | Se leyó route.snapshot una sola vez y el router reutiliza el componente | paramMap, toSignal o withComponentInputBinding() con input() |
| Los resolvers no se reejecutan al cambiar de página | runGuardsAndResolvers por defecto ignora los cambios de query params | runGuardsAndResolvers: 'paramsOrQueryParamsChange' en esa ruta |
| La petición sale sin parámetros | HttpParams.set() devuelve una copia y no se reasignó | let p = p.set(...), o construir con {fromObject} |
| Un interceptor no se ejecuta | Registrado con withInterceptorsFromDi() sin proveer la clase en HTTP_INTERCEPTORS; o se llamó a provideHttpClient() otra vez en una ruta diferida; o el código usa fetch nativo | Usar withInterceptors([...]) en la raíz; si hace falta un injector hijo, añadir withRequestsMadeViaParent() |
NG0203: inject() must be called from an injection context | inject() llamado dentro de un catchError, switchMap o setTimeout del interceptor o del guard | Inyectar todo al principio de la función, de forma síncrona, y capturar las referencias en constantes |
Ráfaga infinita de peticiones a /auth/refresh | La llamada de refresco pasa por el propio interceptor, o la petición reintentada no se marca | Marcar con HttpContextToken (SIN_AUTH y REINTENTADA) y compartir un único flujo de refresco |
| Se cierra la sesión al caducar el token con varias peticiones en vuelo | Cada 401 disparó su propio refresco y el backend rota el refresh token | Flujo compartido con shareReplay guardado en el servicio (no en una variable de módulo) |
Error de CORS y status: 0 | El navegador bloqueó la respuesta: falta Access-Control-Allow-Origin, o es * con credenciales | Configurar CORS en el backend; en desarrollo, usar el proxy del CLI. No es algo que se pueda arreglar desde el cliente |
| No se puede leer una cabecera de la respuesta | No está en Access-Control-Expose-Headers | Exponerla en el servidor y leerla con observe: 'response' |
| El guard bloquea pero el usuario no se entera | Devuelve false en lugar de un destino | Devolver router.createUrlTree([...]) con el returnUrl |
| La aplicación se ralentiza al navegar mucho | Suscripción a router.events u otro observable infinito sin cancelar | takeUntilDestroyed(), AsyncPipe o toSignal |
routerLink recarga la página completa | No se importó RouterLink en el componente standalone | Añadirlo a imports; usar [routerLink] para que el error sea de compilación |
| El lazy loading no separa el chunk | Algo del bundle inicial importa un símbolo de la funcionalidad diferida, o el import() no es una cadena literal | Revisar los imports con el visualizador de bundle; usar import type para los tipos |
| 404 al recargar una ruta profunda en producción | El servidor busca un fichero físico en esa ruta | Fallback a index.html en el servidor; como último recurso, withHashLocation() |
| El botón atrás pierde la posición del scroll | scrollPositionRestoration está desactivado por defecto | withInMemoryScrolling({scrollPositionRestoration: 'enabled'}) |
| No hay barra de progreso al subir un fichero | withFetch() no emite eventos de progreso de subida | Desactivar withFetch() para ese caso o usar XMLHttpRequest directamente |
| El panel del outlet auxiliar no aparece | Falta <router-outlet name="...">, o el nombre no coincide con el de la ruta | Comprobar que el outlet de la ruta y el name del outlet son idénticos |
6.18 Buenas y malas prácticas
Haz esto
- Un fichero de rutas por funcionalidad, cargado con
loadChildren. Es la unidad natural de división del bundle y de propiedad del código. - Guards y resolvers funcionales, pequeños y componibles; fábricas cuando necesites parametrizarlos.
- Devuelve
UrlTreedesde los guards en vez defalsemás una navegación. canMatchpara lo que decide qué ruta es ycanActivatepara lo que decide si se puede entrar.- Un servicio de API por recurso: los componentes no conocen
HttpClientni las URLs. - Interceptores para lo transversal (token, idioma, reintentos, trazas) y
HttpContextTokenpara las excepciones. - Ata toda suscripción al ciclo de vida:
AsyncPipe,toSignalotakeUntilDestroyed(). - Presupuestos de bundle en CI y una revisión periódica del contenido de los chunks.
- Mapea los errores HTTP a un tipo de dominio en un único sitio.
- Valida en el límite lo que llega del servidor si el contrato no es tuyo.
Evita esto
route.snapshotenngOnInitpara leer parámetros que pueden cambiar sin recrear el componente.- Resolvers para todo. Cada resolver es tiempo en el que la pantalla no cambia y el usuario no sabe si su clic ha servido.
- Suscripciones anidadas (
subscribedentro desubscribe): sin cancelación y con respuestas que se adelantan unas a otras. - Enviar el
Authorizationa cualquier dominio desde un interceptor que no comprueba la URL. - Estado mutable de módulo en un interceptor: se comparte entre peticiones en SSR.
- Reintentar peticiones no idempotentes sin clave de idempotencia.
- Confiar en los guards como seguridad o esconder datos sensibles en un chunk diferido.
- Guardar tokens de larga vida en
localStoragesin haber valorado la alternativa. skipLocationChangepor comodidad: rompe el enlace compartible y el botón atrás.- Montar NgRx «porque el proyecto va a crecer» antes de tener un problema real de estado.
6.19 Preguntas frecuentes
¿provideRouter o RouterModule.forRoot?
provideRouter en todo proyecto nuevo. Es la API alineada con los componentes standalone, permite eliminar del bundle las funcionalidades que no usas y su configuración es explícita función a función. RouterModule.forRoot sigue funcionando por compatibilidad, y en la plantilla sigue siendo necesario importar las directivas (RouterLink, RouterOutlet) en cada componente que las use.¿Por qué mi canActivate no impide que se descargue el chunk?
loadChildren, el router necesita las rutas hijas para poder emparejar, así que descarga el chunk en la fase de reconocimiento, antes de los guards: solo canMatch lo evita. Con loadComponent, la carga ocurre después de los guards, así que canActivate sí la impide. En ninguno de los dos casos consideres esto una medida de seguridad.¿Debo pasar a httpResource() y abandonar HttpClient?
httpResource() es una comodidad para lecturas que dependen de señales: elimina el estado de carga y error escrito a mano y cancela la petición anterior. Por debajo usa HttpClient y pasa por tus interceptores. Las escrituras, los flujos compuestos y los servicios de API reutilizables siguen siendo terreno de HttpClient con RxJS. Además es una API experimental: comprueba la versión antes de basar una arquitectura en ella.¿Cómo pruebo un guard funcional?
TestBed.runInInjectionContext(() => authGuard(rutaFalsa, estadoFalso)), con los servicios sustituidos por dobles en los providers del TestBed. Es una de las grandes ventajas frente a los guards de clase: no hace falta instanciar nada ni montar un RouterTestingModule. Para comprobar la redirección, verifica que el resultado es un UrlTree y compara su serialización.¿Qué pasa con las peticiones en curso cuando el usuario navega a otra pantalla?
AsyncPipe, toSignal, takeUntilDestroyed() o un recurso de señales, sí. Con un subscribe manual sin desuscripción, no: la petición continúa, la respuesta llega a un componente destruido y, si esa respuesta escribe en un estado compartido, puedes ver datos de una pantalla que ya no existe.¿Puedo tener interceptores distintos para dos APIs diferentes?
req.url y actúe según el destino. El más limpio: proveer un HttpClient en el injector de una ruta o de un componente con provideHttpClient(withInterceptors([...]), withRequestsMadeViaParent()), de modo que se añadan los locales sin perder los globales. Para clientes totalmente independientes conviene un token de inyección propio que devuelva un HttpClient configurado aparte.¿Cómo comparto datos entre rutas hermanas sin repetir la petición?
providers: [FeatureStore]): las hijas inyectan la misma instancia y el estado muere al salir de esa rama. Si el dato es de solo lectura y viene del servidor, un resolver en el padre más route.parent.data también funciona. Lo que no conviene es un servicio raíz con estado de una funcionalidad concreta: sobrevive a la navegación y acaba con datos rancios.¿Los query params deben ir en el estado de la aplicación o en la URL?
¿Cuándo debo preocuparme por el tamaño del bundle inicial?
angular.json desde el primer día y revísalo en cada pull request. Una referencia razonable para una aplicación de gestión es mantener el JavaScript inicial por debajo de unos cientos de kilobytes comprimidos; lo importante no es la cifra exacta, sino que no crezca sin que nadie se dé cuenta.¿SSE o WebSocket para notificaciones?
Last-Event-ID. WebSocket cuando de verdad necesites enviar datos del cliente al servidor con frecuencia (chat, colaboración en tiempo real, juegos). El precio de SSE es que no puedes poner cabeceras propias, así que la autenticación va por cookie o por un ticket de un solo uso.¿Es obligatorio duplicar DTO y modelo si el backend también es mío?
Date, enumerados legibles y campos calculados, y el contrato de transporte quiere estabilidad y compatibilidad hacia atrás. Un mapeador de diez líneas evita que un cambio en la base de datos se propague hasta las plantillas.6.20 Ejercicios
6.1 Monta una aplicación con las rutas / (redirige a /tareas), /tareas, /tareas/:id y una página 404 con path: '**'. Comprueba en las DevTools qué chunks se descargan y cuándo. Después quita el pathMatch: 'full' de la redirección y explica con precisión qué error aparece y por qué.
6.2 Convierte un componente de detalle que lee route.snapshot.paramMap a la forma con withComponentInputBinding() e input.required(). Añade un enlace «siguiente tarea» dentro de la propia pantalla y demuestra que el componente se reutiliza y que la versión con snapshot se quedaba congelada.
6.3 Escribe un servicio de API para un recurso con los cinco métodos habituales, tipado, y una llamada paginada que lea el total de la cabecera X-Total-Count con observe: 'response'.
6.4 Añade un indicador global de carga con un interceptor y un contador en una señal. Comprueba que con dos peticiones simultáneas no se apaga antes de tiempo, y añade una excepción con HttpContextToken para el autocompletar.
6.5 Implementa el guard de autenticación con returnUrl y su contrapartida en el login. Protege el redirector abierto y escribe una prueba que verifique el UrlTree devuelto.
6.6 Escribe un guard de «cambios sin guardar» con canDeactivate y un diálogo propio que devuelva un observable. Añade el beforeunload y explica qué caso cubre cada uno.
6.7 Implementa una estrategia de precarga que solo cargue las rutas con data.preload === true, espere dos segundos tras el arranque y se abstenga si navigator.connection.saveData es cierto. Mide la diferencia con PreloadAllModules.
6.8 Escribe el interceptor de reintentos con retroceso exponencial y jitter, que solo actúe sobre métodos idempotentes y sobre los códigos 0, 429 y 5xx. Pruébalo con HttpTestingController.
6.9 Convierte una ruta con resolver en una ruta que navega de inmediato y muestra un esqueleto con un recurso de señales. Compara ambas versiones con la red limitada a 3G lento en las DevTools y describe qué percibe el usuario en cada caso.
6.10 Implementa el interceptor de refresco de token completo: flujo compartido en el servicio, contexto para evitar el bucle, cierre de sesión si el refresco falla. Escribe una prueba que lance tres peticiones concurrentes que devuelvan 401 y verifique que solo se envía una llamada a /auth/refresh.
6.11 Construye una caché HTTP con invalidación por etiquetas: cada GET declara sus etiquetas en el contexto y cada escritura invalida las etiquetas que afecta. Añade tiempo de vida y un límite de entradas con descarte del menos usado recientemente.
6.12 Añade un panel de detalle en un outlet auxiliar cuya apertura sea compartible por URL, con navegación relativa correcta desde la lista y un botón de cierre. Explica qué pasa con el botón atrás.
6.13 Implementa un servicio de notificaciones con SSE que sobreviva a cortes de red, reanude con Last-Event-ID y escriba en una señal. Después reimplementa lo mismo con WebSocket y compara el código, la reconexión y el consumo.
6.14 Coge una funcionalidad con estado no trivial y escríbela dos veces: con un servicio de señales y con NgRx SignalStore. Mide líneas de código, ficheros tocados para añadir un campo nuevo y facilidad para depurar un bug provocado a propósito. Redacta tu conclusión en cinco líneas.
Solución comentada · 6.10 · interceptor de refresco de token correcto
La clave está en dónde vive el estado compartido y en cómo se corta el bucle. Tres piezas: un token de contexto para no interceptar la propia llamada de refresco, otro para marcar lo ya reintentado, y un observable compartido guardado en el servicio (no en el módulo, que en SSR se comparte entre usuarios).
// core/auth/refresco.ts
export const SIN_AUTH = new HttpContextToken<boolean>(() => false);
export const REINTENTADA = new HttpContextToken<boolean>(() => false);
// AuthService.refrescar() es el de 6.12.1: this.refresco$ ??= ... con finalize
// (libera el hueco) y shareReplay (todos los suscriptores ven el mismo valor),
// y el observable guardado en el SERVICIO, nunca en una variable de módulo.
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const auth = inject(AuthService); // inyectar SIEMPRE de forma síncrona
const token = auth.accessToken();
const salida = token && !req.context.get(SIN_AUTH)
? req.clone({setHeaders: {Authorization: `Bearer ${token}`}})
: req;
return next(salida).pipe(
catchError((error: unknown) => {
const es401 = error instanceof HttpErrorResponse && error.status === 401;
// Las tres condiciones que cortan cualquier bucle posible.
if (!es401 || req.context.get(SIN_AUTH) || req.context.get(REINTENTADA)) {
return throwError(() => error);
}
return auth.refrescar().pipe(
switchMap((nuevo) => next(req.clone({
setHeaders: {Authorization: `Bearer ${nuevo}`},
context: req.context.set(REINTENTADA, true),
}))),
catchError((e) => { auth.cerrarSesion(); return throwError(() => e); }),
);
}),
);
}; Cómo se prueba. Con HttpTestingController: se lanzan tres GET, se responde a los tres con 401, se comprueba que expectOne('/api/auth/refresh') no falla (falla si hay dos o ninguna) y se verifica que después llegan tres peticiones nuevas con el token actualizado. Si tu implementación dispara tres refrescos, expectOne lo detecta y la prueba se pone roja: exactamente el bug que se busca evitar.
Solución comentada · 6.5 · guard con returnUrl a prueba de redirectores abiertos
Dos mitades que se suelen escribir por separado y hay que pensar juntas. El guard captura el destino; el login lo consume, pero nunca confía en él, porque viene de la query string y puede haberlo puesto un atacante.
// core/guards/auth.guard.ts
export const authGuard: CanActivateFn = (_ruta, estado) => {
const sesion = inject(SesionStore);
const router = inject(Router);
if (sesion.autenticado()) return true;
// estado.url es la URL COMPLETA de destino, con query params y fragmento:
// exactamente lo que hay que restaurar después del login.
return router.createUrlTree(['/login'], {queryParams: {returnUrl: estado.url}});
};
// auth/login.component.ts
export class LoginComponent {
readonly returnUrl = input<string>('/'); // vía withComponentInputBinding
private readonly router = inject(Router);
async entrar(datos: Credenciales): Promise<void> {
await this.auth.iniciarSesion(datos);
await this.router.navigateByUrl(this.destinoSeguro(), {replaceUrl: true});
}
private destinoSeguro(): string {
const url = this.returnUrl();
// '//malicioso.example' y 'https://malicioso.example' son URLs válidas para
// navigateByUrl y sacarían al usuario de la aplicación con la sesión recién
// creada. Solo se acepta una ruta interna: una barra, y solo una.
const interna = url.startsWith('/') && !url.startsWith('//');
if (!interna) return '/';
// Extra recomendable: comprobar que el router sabe emparejar esa URL, para
// no aterrizar en el 404 tras un login correcto.
return url;
}
} Detalles que marcan la diferencia: replaceUrl: true evita que el botón atrás devuelva al formulario de login ya usado; usar estado.url en lugar de estado.root.url
conserva los query params del destino; y devolver un UrlTree en lugar de navegar dentro del guard mantiene la navegación como una sola operación atómica que el router puede cancelar limpiamente.
6.21 Resumen del capítulo
- El router convierte la URL en un
UrlTreey de ahí en unRouterState: un árbol deActivatedRouteque refleja los outlets montados. - La navegación tiene fases: reconocimiento (
canMatchy chunks deloadChildren), guards (canDeactivate,canActivateChild,canActivate), resolvers, carga deloadComponenty activación. Saber en qué fase ocurre cada cosa explica casi todos los comportamientos «raros». pathMatch: 'full'en toda ruta vacía que redirige;'prefix'en la que tiene componente o hijas.- El
snapshotse congela cuando el router reutiliza el componente. Usa los observables owithComponentInputBinding()coninput(). - Guards funcionales, y devuelve
UrlTreepara redirigir. Los guards son experiencia de usuario, no seguridad. - Menos resolvers y más esqueletos: un resolver lento es una pantalla congelada sin explicación.
HttpParamsyHttpHeadersson inmutables: reasigna siempre el resultado.- Los interceptores son una cebolla: la petición baja en el orden del array y la respuesta sube en el inverso. Usa
HttpContextTokenpara las excepciones, no expresiones regulares sobre la URL. - El refresco de token necesita un flujo compartido guardado en un servicio, y marcas de contexto para cortar el bucle.
- DTO y modelo de dominio separados por un mapeador, errores HTTP traducidos a un tipo de dominio, y toda suscripción atada al ciclo de vida.
- SSE cubre el 80 % del tiempo real; WebSocket cuando de verdad hace falta bidireccionalidad.
- Empieza con señales y sube a SignalStore o a NgRx solo cuando puedas nombrar el requisito que lo justifica.
- CORS lo impone el navegador, no protege tu API, y el frontend nunca es una frontera de seguridad.
6.22 Recursos adicionales
- Angular · Guía de routing — la referencia oficial, actualizada con las APIs funcionales y standalone.
- Angular · API de
provideRouter— la lista completa y siempre vigente de features del router. - Angular ·
HttpCliente interceptores — configuración, interceptores, pruebas y opciones de petición. - MDN · CORS — explicación detallada del preflight y de cada cabecera, en español.
- MDN · Server-sent events — formato del flujo, reconexión y
Last-Event-ID. - OWASP · Prevención de CSRF — el patrón double submit y sus alternativas, con criterio.
- NgRx · SignalStore — documentación oficial de
signalStore,withEntitiesy los features personalizados. - web.dev · Core Web Vitals — para medir el impacto real de las decisiones de carga diferida y precarga.