Parte II · Angular

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.

CORE ANGULAR Tiempo de lectura: ~110 min Prerrequisitos: capítulos 2 a 5 (componentes, señales, RxJS, plantillas)

6.1 Qué vas a poder hacer al terminar

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.

Analogía El router es el recepcionista de un edificio de oficinas. La dirección postal del edificio es una sola (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:

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'
Tres conceptos que se confunden constantemente

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 · snapshot

Cada propiedad existe en dos formas y esa dualidad es la fuente de la mitad de los errores del capítulo:

FormaTipoQué representaCuá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)  ─►  TitleStrategy
Consecuencias prácticas del diagrama

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

app/core/telemetria-navegacion.ts
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.

Fuga de memoria clásica 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.

app/app.config.ts
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

FeatureQué haceCaso 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.
Sobre 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.
Cuidado con el orden de las features y con llamar dos veces 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.

app/app.routes.ts
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')},
];
PropiedadPara qué sirveDetalle que se pasa por alto
pathPatrón de segmentos, con parámetros :nombreSin 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.
componentComponente a instanciar en el outletEs incompatible con loadComponent y con redirectTo.
loadComponentComponente standalone diferidoSe descarga después de los guards y resolvers.
loadChildrenArray de rutas hijas diferidoSe descarga durante el emparejamiento: solo canMatch lo evita.
redirectToRedirección internaIncompatible con canActivate (la redirección ocurre antes). Admite parámetros del path original.
childrenSubrutas que se pintan en el router-outlet del padreSin componente padre, sirve para agrupar sin tocar el DOM.
outletNombre del outlet destinoPor defecto 'primary'. Una ruta con outlet propio no aparece en la URL principal, sino entre paréntesis.
dataMetadatos estáticosLos heredan las hijas según paramsInheritanceStrategy. Se mezclan con lo que devuelven los resolvers.
resolveDatos que deben estar listos antes de activarSus claves acaban también en data, así que no las repitas.
titleTítulo de la pestaña, literal o funciónLo aplica TitleStrategy; se toma el título de la ruta activa más profunda que lo declare.
providersServicios con ámbito de esa rama de rutasCrean un injector hijo: útil para estado por funcionalidad que debe morir al salir.
runGuardsAndResolversCuá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.

app.routes.tsINCORRECTO
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},
];
app.routes.tsCORRECTO
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},
];
La regla mnemotécnica

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.

app/core/titulo.strategy.ts
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}
tareas/tareas.routes.ts · título dinámico a partir del resolver
// 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

tareas/detalle.component.ts · lectura reactiva con RxJS
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.

detalle.component.tsINCORRECTO
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));
  }
}
detalle.component.tsCORRECTO
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},
  );
}
Los guards y resolvers también se reejecutan (o no) Ese mismo mecanismo controla 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.

tareas/detalle.component.ts · sin ActivatedRoute
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)});
}
Detalles que muerden con 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.

tareas/lista.component.html
<!-- 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>
El enlace que recarga toda la página En componentes standalone hay que importar explícitamente 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.
tareas/editar.component.ts
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);
}
NavigationExtrasEfectoCuándo se usa de verdad
stateGuarda un objeto serializable en history.statePasar un mensaje de éxito o el origen de la navegación sin ensuciar la URL.
skipLocationChangeNavega y activa componentes, pero no toca la barra de direccionesMostrar un modal enrutado sin cambiar la URL, o vistas internas de un asistente. Rompe el enlace compartible: úsalo con cuidado.
replaceUrlSustituye la entrada actual del historialTras enviar un formulario, tras un login, o al redirigir desde una URL obsoleta.
queryParamsHandling'merge' combina, 'preserve' conserva los actualesFiltros 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 cambieBotó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.

tareas/detalle.component.ts
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!);
    }
  }
}
Límites del 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.

firmas de @angular/router (simplificadas)
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>;
RetornoSignificado
trueAdelante.
falseSe cancela (NavigationCancel). El usuario se queda donde estaba, sin ninguna pista de por qué.
UrlTreeSe cancela y se lanza inmediatamente una navegación a ese árbol. Es la forma correcta de redirigir.
Observable / PromiseEl router espera el primer valor emitido; el observable debe emitir y, preferiblemente, completar.
RedirectCommandRedirección con NavigationBehaviorOptions (por ejemplo replaceUrl). API reciente: comprueba tu versión.
Por qué los guards de clase están obsoletos

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

auth.guard.tsINCORRECTO
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;
};
auth.guard.tsCORRECTO
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},
  });
};
auth/login.component.ts · el otro extremo del returnUrl
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

core/guards/rol.guard.ts
// 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']);
  };
}
app.routes.ts · dos componentes para la MISMA URL
// 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'),
  },
];
canMatchcanActivate
FaseReconocimiento (antes de conocer la ruta definitiva)Fase de guards (la ruta ya está decidida)
Si devuelve falseEl router sigue probando las rutas siguientesLa navegación se cancela
Chunk de loadChildrenNo se descargaYa se ha descargado
Chunk de loadComponentNo se descargaNo se descarga (se carga tras los guards)
RecibeRoute y UrlSegment[]: sin parámetros resueltosEl ActivatedRouteSnapshot completo: parámetros y data
Úsalo paraFeature flags, rutas por rol o por plan de suscripción, tests A/BAutorizar el acceso, comprobar precondiciones que dependen del parámetro
Ni 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

core/guards/cambios-sin-guardar.guard.ts
// 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.

tareas/tarea.resolver.ts
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;
    }),
  );
};
Dos comportamientos que sorprenden

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.
tareas/detalle.component.ts · la alternativa con esqueleto
@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),
  });
}
Criterio del arquitecto La tendencia en Angular moderno es menos resolvers. Los recursos basados en señales dan cancelación automática, estado de carga y error, y recarga, sin bloquear la navegación. Reserva los resolvers para lo que de verdad condiciona si la ruta puede activarse.

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.

tareas/tareas.routes.ts · rutas por funcionalidad
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.
El 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.

core/precarga-selectiva.strategy.ts
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: ...}
Precarga por intención del usuario Una alternativa que suele dar mejor resultado que cualquier heurística: precargar cuando el usuario señala que va a ir. Se implementa con un 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

terminal
# 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.
angular.json · presupuestos que rompen el build
{
  "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.

admin/admin-layout.component.html
<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>
admin/admin.routes.ts
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')},
    ],
  },
];
admin/usuarios.component.ts · abrir y cerrar el panel lateral
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});
}
Los outlets con nombre son potentes y frágiles La URL resultante (/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

app.routes.ts
{
  // 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')},
  ],
}
Herencia de 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 provideHttpClientQué 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.

tareas.api.tsINCORRECTO
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});
}
tareas.api.tsCORRECTO
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});
}
Detalles de 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.

tareas/tareas.api.ts · observe, responseType y cabeceras
@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'});
  }
}
El genérico es una promesa, no una comprobación 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

ficheros/ficheros.api.ts
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

core/http/error-dominio.ts
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

API reciente y experimental 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.
tareas/lista.component.ts
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
TransporteSiempre HttpClient (pasa por los interceptores)Cualquiera: fetch, WebSocket, IndexedDB, SDK de tercerosHttpClient
Estado de carga y errorIncluido en señalesIncluido en señalesLo escribes tú
Escrituras (POST/PUT)No es su caso de uso: está pensado para lecturasNoSí, es lo natural
Composición avanzadaLimitadaLimitadaTotal: debounceTime, retry, combineLatest
Cuándo usarloUn GET que depende de señales de la interfaz (filtros, paginación)Igual, pero con un origen que no es HTTPMutaciones, 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.
core/http/contexto.ts · desactivar un interceptor por petición
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)});
core/http/interceptores.ts · token, idioma, tiempos e indicador de carga
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()));
};
core/http/indicador-carga.service.ts
@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)); }
}
core/http/interceptores.ts · reintento, caché y fechas
// 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),
  );
El interceptor de fechas: cómodo pero discutible Recorre recursivamente cada respuesta (coste en cargas grandes), convierte cadenas que solo parecen fechas y, sobre todo, hace que el tipo declarado mienta: 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.
auth.interceptor.tsINCORRECTO
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))),
      );
    }),
  );
};
auth.interceptor.tsCORRECTO
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}`}});
}
core/auth/auth.service.ts · el flujo compartido, la pieza clave
@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});
  }
}
Alternativa que evita el problema de raíz Refrescar antes de que el token caduque. Si el access token incluye su fecha de expiración, un interceptor puede comprobar si le quedan menos de 30 segundos y esperar al refresco antes de enviar la petición. Se elimina el 401 y con él la mayor parte de la complejidad. Requiere que los relojes del cliente y del servidor estén razonablemente sincronizados, así que conviene dejar margen.

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.

tareas/tarea.model.ts · DTO, modelo y mapeadores
// 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.

tareas/tareas.api.ts · caché, deduplicación e invalidación
@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(); }
}
lista.component.tsINCORRECTO
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.
}
lista.component.tsCORRECTO
// 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: []},
);
Cancelación al navegar Cuando el usuario abandona una pantalla, sus peticiones deberían morir con ella. Ocurre solo si la suscripción está atada al ciclo de vida: 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

core/tiempo-real.ts · WebSocket y SSE
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
  });
}
CriterioPolling (HTTP repetido)SSE (EventSource)WebSocket
DirecciónCliente preguntaServidor empujaBidireccional
ProtocoloHTTPHTTP (respuesta abierta)Actualización a ws://
LatenciaMedia = intervalo / 2InmediataInmediata
Coste en servidorAlto si el intervalo es corto y hay muchos clientesUna conexión abierta por clienteUna conexión abierta por cliente, con estado
ReconexiónTrivial (cada petición es nueva)Automática, con Last-Event-IDManual: la implementas tú
Auth y cabecerasTodo lo de HTTP, e interceptores de AngularSin cabeceras propias: cookie o ticketSin cabeceras: ticket o subprotocolo
Proxies y balanceadoresSin problemasCasi siempre bien; desactiva el bufferingRequiere configuración específica y sesiones pegajosas
Elígelo paraDatos que cambian cada minutos: informes, estado de un trabajo por lotesNotificaciones, progreso, precios, feeds: el 80 % de los casos realesChat, edición colaborativa, juegos, telemetría de alta frecuencia
Dos avisos prácticos

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.

tareas.store.ts · servicio con señales
@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; }
  }
}
el mismo caso con NgRx clásico (esqueleto)
// 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.

CriterioServicio con señalesNgRx SignalStoreNgRx Store clásico
Código para un caso simpleMínimoBajoAlto (acciones, reducer, efectos, selectores)
Curva de aprendizajeYa la conocesMedia: withState, withComputed, withMethods, withHooksAlta: flujo unidireccional, inmutabilidad, efectos
DevTools y time travelNo (solo el depurador)Parcial, con la extensión de DevToolsSí, completo
Trazabilidad de quién cambió quéDepende de tu disciplinaBuena si respetas los withMethodsTotal: toda mutación es una acción con nombre
Colecciones y entidadesA manowithEntities del paquete de entidades@ngrx/entity
Coste de mantenimientoCrece si el estado se comparte mucho y nadie pone ordenBajo y predecibleAlto, pero muy uniforme entre equipos
Encaja cuando…Estado por funcionalidad, equipos pequeños, la mayoría de los CRUDPunto medio actual recomendable en Angular modernoEstado global complejo, muchos equipos, auditoría o undo/redo como requisito
Camino recomendado Empieza con servicios y señales, uno por funcionalidad, con el estado privado y lo público de solo lectura. Si un estado empieza a ser tocado por muchos sitios, extráelo a un SignalStore. Reserva NgRx clásico para cuando puedas nombrar el requisito concreto que lo justifica (auditoría, undo, sincronización compleja). Migrar de un servicio ordenado a un store es fácil; migrar de un store innecesario a algo simple, no lo es.

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 real
Los cuatro errores de CORS que verás

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

app.config.ts · XSRF a medida
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ónRiesgo ante XSSRiesgo ante CSRFSobrevive a recargarVeredicto
Variable en memoria (señal de un servicio)Solo mientras la pestaña vive; no queda nada persistidoNinguno (no se envía solo)NoMejor opción para el access token, con refresco en cookie httpOnly
localStorageAlto: cualquier XSS lo lee y lo exfiltra, y persiste entre sesionesNingunoCómodo y muy extendido; aceptable solo con tokens de vida muy corta
sessionStorageAlto, pero limitado a la pestañaNingunoSí, en esa pestañaIgual que el anterior con menos exposición
Cookie httpOnly + Secure + SameSite=Lax/StrictBajo: el JavaScript no puede leerlaExiste: necesita protección CSRFLo 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 navegadorExiste: CSRF más SameSiteEl más seguro; exige un servidor propio delante
El frontend nunca es una frontera de seguridad Todo lo que llega al navegador está en manos del usuario: puede leer el bundle, cambiar el valor de una señal desde la consola, modificar el 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 errorCausa realSolució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 segmentoRevisar el orden del array, comprobar que el path no lleva barra inicial y añadir {path: '**'} al final
Bucle infinito de redirecciones al arrancarRuta 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 muestraSe le puso pathMatch: 'full' teniendo hijas o quedando segmentos por consumirDejar el 'prefix' por defecto en rutas vacías con componente o hijas
La URL cambia pero la vista noSe leyó route.snapshot una sola vez y el router reutiliza el componenteparamMap, toSignal o withComponentInputBinding() con input()
Los resolvers no se reejecutan al cambiar de páginarunGuardsAndResolvers por defecto ignora los cambios de query paramsrunGuardsAndResolvers: 'paramsOrQueryParamsChange' en esa ruta
La petición sale sin parámetrosHttpParams.set() devuelve una copia y no se reasignólet p = p.set(...), o construir con {fromObject}
Un interceptor no se ejecutaRegistrado 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 nativoUsar withInterceptors([...]) en la raíz; si hace falta un injector hijo, añadir withRequestsMadeViaParent()
NG0203: inject() must be called from an injection contextinject() llamado dentro de un catchError, switchMap o setTimeout del interceptor o del guardInyectar todo al principio de la función, de forma síncrona, y capturar las referencias en constantes
Ráfaga infinita de peticiones a /auth/refreshLa llamada de refresco pasa por el propio interceptor, o la petición reintentada no se marcaMarcar 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 vueloCada 401 disparó su propio refresco y el backend rota el refresh tokenFlujo compartido con shareReplay guardado en el servicio (no en una variable de módulo)
Error de CORS y status: 0El navegador bloqueó la respuesta: falta Access-Control-Allow-Origin, o es * con credencialesConfigurar 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 respuestaNo está en Access-Control-Expose-HeadersExponerla en el servidor y leerla con observe: 'response'
El guard bloquea pero el usuario no se enteraDevuelve false en lugar de un destinoDevolver router.createUrlTree([...]) con el returnUrl
La aplicación se ralentiza al navegar muchoSuscripción a router.events u otro observable infinito sin cancelartakeUntilDestroyed(), AsyncPipe o toSignal
routerLink recarga la página completaNo se importó RouterLink en el componente standaloneAñadirlo a imports; usar [routerLink] para que el error sea de compilación
El lazy loading no separa el chunkAlgo del bundle inicial importa un símbolo de la funcionalidad diferida, o el import() no es una cadena literalRevisar los imports con el visualizador de bundle; usar import type para los tipos
404 al recargar una ruta profunda en producciónEl servidor busca un fichero físico en esa rutaFallback a index.html en el servidor; como último recurso, withHashLocation()
El botón atrás pierde la posición del scrollscrollPositionRestoration está desactivado por defectowithInMemoryScrolling({scrollPositionRestoration: 'enabled'})
No hay barra de progreso al subir un ficherowithFetch() no emite eventos de progreso de subidaDesactivar withFetch() para ese caso o usar XMLHttpRequest directamente
El panel del outlet auxiliar no apareceFalta <router-outlet name="...">, o el nombre no coincide con el de la rutaComprobar 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 UrlTree desde los guards en vez de false más una navegación.
  • canMatch para lo que decide qué ruta es y canActivate para lo que decide si se puede entrar.
  • Un servicio de API por recurso: los componentes no conocen HttpClient ni las URLs.
  • Interceptores para lo transversal (token, idioma, reintentos, trazas) y HttpContextToken para las excepciones.
  • Ata toda suscripción al ciclo de vida: AsyncPipe, toSignal o takeUntilDestroyed().
  • 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.snapshot en ngOnInit para 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 (subscribe dentro de subscribe): sin cancelación y con respuestas que se adelantan unas a otras.
  • Enviar el Authorization a 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 localStorage sin haber valorado la alternativa.
  • skipLocationChange por 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?
Depende de cómo esté declarada la ruta. Con 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?
No. 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?
Llamándolo como una función dentro de un contexto de inyección: 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?
Se cancelan solo si la suscripción muere con el componente. Con 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?
Sí, con dos enfoques. El sencillo: un único interceptor que mire 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?
Con un servicio proveído en la ruta padre (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?
En la URL, siempre que representen algo que el usuario querría compartir o recuperar: filtros, página, ordenación, pestaña activa. La URL es el único estado que sobrevive a una recarga y a un correo electrónico. Deja fuera lo efímero (un menú abierto) y lo sensible (nunca un token ni un dato personal en la query string: queda en el historial, en los referrers y en los logs del servidor).
¿Cuándo debo preocuparme por el tamaño del bundle inicial?
Antes de que sea un problema, porque después cuesta mucho más. Fija un presupuesto en 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?
SSE en la gran mayoría de los casos: viaja sobre HTTP normal, atraviesa proxies sin configuración especial, reconecta solo y reanuda con 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?
Obligatorio no; recomendable casi siempre. Compartir tipos en un monorepo elimina la duplicación de la definición, pero no la necesidad de una frontera: el modelo de vista quiere fechas como 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

Nivel 1 · básico

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.

Nivel 2 · intermedio

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.

Nivel 3 · avanzado

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 UrlTree y de ahí en un RouterState: un árbol de ActivatedRoute que refleja los outlets montados.
  • La navegación tiene fases: reconocimiento (canMatch y chunks de loadChildren), guards (canDeactivate, canActivateChild, canActivate), resolvers, carga de loadComponent y 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 snapshot se congela cuando el router reutiliza el componente. Usa los observables o withComponentInputBinding() con input().
  • Guards funcionales, y devuelve UrlTree para 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.
  • HttpParams y HttpHeaders son 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 HttpContextToken para 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

Siguiente paso Ya sabes navegar, dividir el código y hablar con el backend. El capítulo 7 baja un nivel más: qué ocurre exactamente entre la petición del navegador y el primer píxel, y cómo el renderizado en servidor, la hidratación y los presupuestos de rendimiento cambian todas las decisiones de este capítulo.