Ir al contenido

Enrutamiento y Angular Router

Una aplicación Angular es una aplicación de una sola página: el navegador la descarga una vez y, a partir de ahí, JavaScript cambia lo que se ve sin pedirle al servidor una página nueva. Aun así, los usuarios esperan que la dirección (la URL) cambie según lo que están viendo.

El enrutamiento (routing) es el mecanismo que conecta la URL con la vista: cuando la URL cambia, se muestra la vista que le corresponde, y cuando el usuario cambia de vista, la URL cambia. La URL funciona como el estado de la vista: dice qué se está viendo.

Sin enrutamiento, la aplicación tiene una sola URL y todos los componentes viven juntos en app.html, uno debajo de otro. Eso funciona con una o dos listas, pero deja varios problemas, que son justamente para lo que sirve el enrutamiento:

Sin enrutamiento Con enrutamiento
No se puede enviar un enlace al detalle de una película Cada vista tiene su URL: /films/tt0111161 se puede compartir y guardar como marcador
El botón atrás del navegador no lleva a la vista anterior Atrás y adelante recorren las vistas visitadas
Al recargar la página, el usuario vuelve al inicio Al recargar, la URL sigue ahí y la misma vista se vuelve a mostrar
Todo se pinta siempre, aunque el usuario solo quiera ver una cosa Se muestra una vista a la vez, y cada una es un componente independiente
Para mostrar “esta película” hay que guardar la selección en memoria El identificador viaja en la URL

Cualquier librería de enrutamiento, en cualquier framework, ofrece tres cosas. Si usted las identifica en una librería que no conoce, ya sabe cómo se usa:

  1. Definir las rutas. Una tabla que asocia un patrón de URL con una vista. El patrón puede tener una parte variable (/films/:id), y la tabla suele incluir una ruta de respaldo para las URL que nadie atiende y redirecciones de una URL a otra.
  2. Llamar a las rutas (navegar). Dos formas: un enlace que el usuario pulsa, escrito en el template, y una instrucción desde el código para las navegaciones que ocurren como consecuencia de otra cosa (por ejemplo, volver a la lista después de guardar).
  3. Indicar dónde se muestra la vista. Un punto de montaje dentro del diseño de la página: la vista elegida aparece ahí, y lo que está alrededor (el menú, el encabezado) permanece.

Escrito sin ningún framework, las tres ideas se ven así:

rutas:
"/films" → vista ListaDePeliculas
"/films/:id" → vista DetalleDePelicula
cualquier otra → vista NoEncontrada
enlace: <enlace a="/films/tt0111161">Ver detalle</enlace>
código: navegar("/films")
dónde: <punto-de-montaje> ← aquí aparece la vista elegida

Además de esas tres, un router suele dejar leer los parámetros de la URL (el :id) y marcar el enlace de la vista actual en un menú.

Usted ya usa enrutamiento en el back: Spring asocia una URL con un método del controller. El enrutamiento del front asocia una URL con una vista. La idea es la misma, y cambia lo que produce cada uno:

Back (Spring) Front (router)
Define las rutas con @RequestMapping y @GetMapping Una tabla de rutas
Parte variable /{id} con @PathVariable :id con un parámetro de ruta
Qué produce Una respuesta HTTP con datos (JSON) Una vista en pantalla
Quién lo ejecuta El servidor, por cada petición El navegador, sin pedir una página nueva
Si ninguna ruta coincide Responde 404 Muestra la vista de “no encontrado”

Los frameworks no comparten librería ni sintaxis, pero las tres ideas se mantienen. La siguiente tabla muestra las diferencias de sintaxis, pero los mismos conceptos, en Angular, React y Vue:

Idea Angular React (React Router) Vue (Vue Router)
Definir las rutas Arreglo Routes Elementos <Route path element> Arreglo routes
Llamar: enlace routerLink <Link to> <router-link to>
Llamar: desde código Router.navigate useNavigate router.push
Dónde se muestra <router-outlet /> <Outlet /> <router-view />

Este curso usa Angular, así que las columnas de React y Vue son solo de referencia. En cualquiera de los tres, el trabajo es el mismo: definir las rutas, llamar a las rutas y definir dónde se muestra la vista del componente correspondiente. Lo que sigue es ese recorrido con Angular, después de registrar el Router en la aplicación.

Idea En Angular
Definir las rutas El arreglo routes, en app.routes.ts
Llamar a las rutas El enlace routerLink y el servicio Router
Dónde se muestra la vista El componente <router-outlet />

Antes de definir las rutas, hay que registrar el Router en la aplicación. Esto se hace en el archivo app.config.ts:

app.config.ts
import { provideRouter, withComponentInputBinding } from '@angular/router';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(),
provideRouter(routes, withComponentInputBinding()),
],
};
  • routes es la tabla que dice qué componente corresponde a cada URL.
  • provideRouter(routes) registra esa tabla en la aplicación, igual que provideHttpClient() registra el cliente HTTP. withComponentInputBinding() lo verá más abajo, en los parámetros de ruta.

Recorre el camino de una URL

Compara una URL que coincide con una ruta y una que no.

NavegadorPaso 1 de 3
El usuario abre la URL

Escribe la dirección o hace clic en un enlace.

/films

Las rutas se definen en un arreglo de tipo Routes, que en esta aplicación vive en el archivo app.routes.ts:

app.routes.ts
export const routes: Routes = [
{ path: '', redirectTo: 'films', pathMatch: 'full' },
{ path: 'films', component: FilmList },
{ path: 'films/:id', component: FilmDetail },
{ path: '**', component: NotFound },
];

Cada ruta asocia un path con un component. Por ejemplo, { path: 'films', component: FilmList } asocia la ruta films con el componente FilmList: cuando se llama a esa ruta, Angular crea el componente FilmList y muestra su vista en el lugar que se haya definido para eso (el <router-outlet />, que se explica más abajo). Las rutas con redirectTo y la comodín son casos especiales, que se ven a continuación.

  • path es el trozo de URL, sin la barra inicial. 'films' responde a /films.
  • redirectTo envía una URL a otra. La raíz (path: '') redirige a films, para que la aplicación nunca arranque en blanco. pathMatch: 'full' hace que solo se redirija la raíz exacta, y Angular lo exige: una redirección desde path: '' sin pathMatch lanza el error NG04014 al arrancar. La razón es que, por defecto, una ruta coincide por prefijo, y todas las URL empiezan con el texto vacío.
  • path: '**' es la ruta comodín: coincide con cualquier URL que las anteriores no atendieron. Es la página de “no encontrado”.

En este ejemplo { path: 'films/:id', component: FilmDetail }, :id significa que esa parte de la URL puede variar (/films/1, /films/2). Se explica en Parámetros de ruta.

Para navegar se usa routerLink en vez del href de HTML:

<a routerLink="/films">Películas</a>
<a [routerLink]="['/films', film.imdbID]">Ver detalle</a>
  • routerLink="/films" es un texto fijo. Con una expresión se usa [routerLink] y un arreglo de segmentos: ['/films', film.imdbID] se convierte en /films/tt0111161.
  • routerLink no recarga la página; href sí. Con routerLink el estado de la aplicación sigue vivo (una variable de JavaScript puesta antes de hacer clic sigue ahí), y con un <a href> normal la página se recarga y esa variable desaparece. Por eso todos los enlaces internos de la aplicación usan routerLink.

Un menú de navegación marca además el enlace de la vista actual, con routerLinkActive:

<nav class="navbar navbar-expand navbar-dark bg-dark px-3">
<a class="nav-link text-white" routerLink="/films"
routerLinkActive="active" [routerLinkActiveOptions]="{ exact: true }">
Películas
</a>
<a class="nav-link text-white" routerLink="/directors"
routerLinkActive="active">
Directores
</a>
</nav>

routerLinkActive="active" agrega la clase CSS active al enlace cuando su ruta es la actual, y Bootstrap la usa para resaltarlo. El componente que contiene el menú importa RouterLink y RouterLinkActive.

Una ruta puede tener una parte variable, que se escribe con dos puntos: films/:id. La misma ruta atiende /films/tt0111161 y /films/tt0068646; lo que cambia es el valor de id.

Para que el componente reciba ese valor, se usa un input() con el mismo nombre del parámetro:

film-detail.ts
import { Component, input } from '@angular/core';
@Component({ /* ... */ })
export class FilmDetail {
readonly id = input.required<string>();
}

Esto funciona gracias a withComponentInputBinding(), la función que se agregó a provideRouter: ella conecta los parámetros de la URL con los input() del componente de esa ruta. Sin ella, el componente no recibe el id. Es el mismo input() con el que un componente padre le pasa datos a un hijo, y encaja con los Signals: id() es una señal.

Dos detalles:

  • El valor llega como texto. typeof id() es 'string', aunque el id de la película sea un número. Con ids numéricos se convierte al declarar el input: input.required({ transform: numberAttribute }).
  • Hay una alternativa, el servicio ActivatedRoute. Se inyecta y se lee con route.snapshot.paramMap.get('id'). Tiene una trampa: el snapshot es una foto de la URL en el momento de crear el componente. Al navegar de /snapshot/1 a /snapshot/2 desde la misma pantalla, el componente se reutiliza y el snapshot sigue diciendo 1. Con input() el valor se actualiza solo.

A veces la navegación no la dispara un enlace sino una acción: por ejemplo, volver al detalle después de guardar algo. Para eso se inyecta el Router:

import { Router } from '@angular/router';
export class FilmDetail {
private readonly router = inject(Router);
volverALaLista() {
this.router.navigate(['/films']);
}
verOtra(id: string) {
this.router.navigate(['/films', id]);
}
}

navigate recibe el mismo arreglo de segmentos que [routerLink]. La regla práctica: un enlace que el usuario ve y pulsa es un routerLink; una navegación que ocurre como consecuencia de otra cosa es un router.navigate.

Se llama a una ruta cuando el usuario hace clic en un enlace o cuando el código usa router.navigate. La vista del componente asociado a esa ruta se muestra donde esté el <router-outlet /> activo. Por ejemplo, con este template:

app.html
<h1>Bienvenidos a la página de ...</h1>
<router-outlet />

la vista aparece debajo del título “Bienvenidos a la página de …”, y el título permanece al cambiar de ruta.

  • <router-outlet /> es el hueco del template donde Angular pinta el componente de la ruta activa. El componente raíz importa RouterOutlet para poder usarlo.

Un <router-outlet /> sin nombre, el principal, muestra una sola vista a la vez: al llamar otra ruta, la nueva reemplaza a la anterior. Hay pantallas donde varias vistas independientes conviven, por ejemplo un menú a la izquierda, una ayuda a la derecha y un encabezado arriba, cada una abierta o cerrada sin afectar a las otras. Para eso se usan outlets con nombre: cada hueco del template lleva un name, y cada ruta dice en cuál se pinta.

app.html
<router-outlet name="arriba" />
<router-outlet name="izquierda" />
<router-outlet name="derecha" />
app.routes.ts
export const routes: Routes = [
{ path: 'encabezado', component: Encabezado, outlet: 'arriba' },
{ path: 'menu', component: Menu, outlet: 'izquierda' },
{ path: 'ayuda', component: Ayuda, outlet: 'derecha' },
];

La propiedad outlet de la ruta es la que la une con un hueco. Una ruta sin outlet va al principal.

Para abrir una vista en un outlet, el routerLink recibe un objeto outlets. Para cerrarla, se pone null:

<a [routerLink]="[{ outlets: { arriba: ['encabezado'] } }]">Abrir el encabezado</a>
<a [routerLink]="[{ outlets: { arriba: null } }]">Cerrar el encabezado</a>

El enlace solo menciona el outlet que cambia: los otros quedan como estaban. Con router.navigate es el mismo arreglo: router.navigate([{ outlets: { izquierda: ['menu'] } }]).

Los outlets con nombre se escriben en la URL entre paréntesis, separados por //:

Abiertos URL
Solo el menú /(izquierda:menu)
La ayuda y el menú /(derecha:ayuda//izquierda:menu)
Los tres /(arriba:encabezado//derecha:ayuda//izquierda:menu)
Ninguno /

Por eso pegar una de esas URL en el navegador reconstruye la pantalla completa, y el botón “atrás” deshace el último cambio de un outlet.

Hasta este tema, el detalle recibía el dato ya cargado: la lista traía las películas, un signal compartido guardaba la seleccionada y el componente de detalle la mostraba, sin pedir nada al servidor. Con el Router aparece una segunda forma, y cada una sirve para una situación distinta.

Detalle con el dato en memoria Detalle por ruta
De dónde sale el dato De un signal compartido que la lista llenó al hacer clic Del servidor: el componente pide el recurso con el id de la URL
URL propia No: es la misma URL que la lista Sí: /films/tt0111161
Al recargar la página Se pierde: el detalle desaparece Se conserva: la URL trae el id y se vuelve a pedir
Enlace compartible No Sí
Petición extra Ninguna Una por cada detalle que se abre
Cuándo usarlo La lista ya trae todo lo que el detalle muestra El detalle necesita información que la lista no trae

Con el detalle en memoria, tras elegir un artista, el detalle se pinta; al recargar la página, el signal vuelve a estar vacío y el detalle desaparece.

Los dos tipos pueden resolverlos un mismo componente, como se ve en la última sección. Para decidir cuál necesita cada pantalla, hágase tres preguntas:

  1. ¿La lista ya trae todo lo que el detalle necesita? Si sí, el dato en memoria basta.
  2. ¿Alguien debería poder abrir ese detalle con su propio enlace? Si sí, necesita una ruta.
  3. ¿El detalle necesita información que solo tiene el servidor? Si sí, necesita ir a buscarla con el id.

Un ejemplo: el listado de películas trae título, año y póster, y el detalle muestra exactamente eso: el dato en memoria alcanza. Pero si el detalle también mostrara los premios que ganó la película, y el servidor los entrega solo en GET /films/{id}, la lista no los trae: hay que ir al servidor, y para eso el detalle recibe el id por la ruta.

Un componente no decide cómo lo llaman. El detalle de película se puede usar de dos maneras:

  • Desde un padre, con su selector. La lista lo declara en su plantilla, <app-film-detail />, y no le pasa nada por input(): el dato ya está en la signal compartida seleccionada, y el detalle lo lee de ahí.
  • Desde una ruta. El Router crea el componente al visitar /films/:id. Aquí el dato no está en la signal: hay que sacar el id de la URL y pedirlo al servidor.

Esto plantea una pregunta de diseño: ¿quién sabe de dónde viene el dato? Hay cinco respuestas, y cada una tiene un costo:

Alternativa Quién interpreta la ruta Costo
El detalle decide (la de este curso) El propio detalle, con un input() opcional El componente conoce los dos orígenes, pero con una sola línea: if (!this.id()) return
El padre pasa el dato por input(), sin signal compartida El propio detalle, con dos input() opcionales: film (lo pasa el padre) e id (lo pasa el Router) Los hijos del detalle, que antes leían la signal, ahora reciben el dato por input(), nivel por nivel. El compilador no avisa si falta uno de los dos input()
Dos componentes: uno solo de presentación y otro de ruta que le pasa el dato El componente de ruta Un componente más por cada entidad
Un servicio que lee la ruta El servicio Un servicio global que inyecta ActivatedRoute no ve el id de la ruta hija: hay que escuchar los eventos del Router y recorrer el árbol de rutas
Un resolver en la configuración de la ruta La ruta Usa Observable en vez de httpResource, suma conceptos (ResolveFn, RedirectCommand) y el 404 hay que manejarlo aparte, o la pantalla queda en blanco

Las tres últimas dejan al detalle sin saber de la ruta, pero a cambio piden más piezas o conceptos nuevos de los que se ganan. Por eso este curso deja que el detalle lo resuelva: sin id (lo llamó el padre), no hace nada y la lista llena la signal; con id (lo llamó la ruta), lo pide y llena la misma signal. Lo que el padre ya hacía no cambia al agregar la ruta. La sección siguiente lo construye completo.

Hay una segunda diferencia entre los dos llamados, y no depende de quién interpreta la ruta: la forma del dato. Todo GET del back devuelve el DTOCompleto, pero las colecciones que vienen anidadas dentro de él usan el DTO básico, para que el JSON no dé vueltas.

Un ejemplo: GET /films/tt0111161 devuelve la película con su lista actors, y cada actor viene con sus datos básicos, sin su lista films. En cambio GET /actors/12 devuelve el actor completo, con su lista films. Un ActorDetail que se abre de las dos maneras (desde la película, con el actor anidado, o desde /actors/12) recibe objetos de forma distinta.

Por eso el detalle debe resolver qué muestra: la colección completa solo se pinta cuando llegó. Si el actor llegó anidado y la pantalla necesita sus películas, el detalle las pide con el id del actor; si no las necesita, no las muestra. Lo que no puede hacer es suponer que la colección siempre está.

El detalle en memoria que ya conoces lee una signal compartida del servicio: la lista la llena al hacer clic y el componente de detalle (y sus hijos, como la lista de álbumes) la leen. Para atender también la ruta no hace falta otro componente ni otro camino: el Router solo cambia quién llena la signal.

  • Desde la lista, la llena el clic, como antes.
  • Desde la ruta, el componente recibe el id, lo pide al servidor y, cuando llega la respuesta, la respuesta llena la misma signal.

Así, todo lo que cuelga del detalle sigue leyendo un solo lugar. El servicio guarda la selección y ofrece el recurso:

film.service.ts
private readonly _filmSeleccionada = signal<Film | undefined>(undefined);
readonly filmSeleccionada = this._filmSeleccionada.asReadonly();
seleccionar(film: Film | undefined): void {
this._filmSeleccionada.set(film);
}
filmResource(id: () => string | undefined): HttpResourceRef<Film | undefined> {
return httpResource<Film>(() => {
const valor = id();
return valor ? `${this.apiUrl}/${valor}` : undefined;
});
}

Y el componente de detalle:

film-detail.ts
export class FilmDetail {
private readonly filmService = inject(FilmService);
// Detalle por ruta: el Router entrega el id de /films/:id (en la lista no hay id)
readonly id = input<string>();
// Lo que se muestra sale siempre de la signal compartida, como en el detalle en memoria
readonly seleccionada = this.filmService.filmSeleccionada;
// Solo pide al servidor si hay id
readonly recurso = this.filmService.filmResource(() => this.id());
readonly noExiste = computed(() => {
const error = this.recurso.error();
return error instanceof HttpErrorResponse && error.status === 404;
});
constructor() {
effect(() => {
if (!this.id()) return; // sin id, la selección la llena la lista al hacer clic
this.filmService.seleccionar(this.recurso.hasValue() ? this.recurso.value() : undefined);
});
}
}
film-detail.html
@if (recurso.isLoading()) {
<p>Cargando…</p>
}
@if (noExiste()) {
<p>Esa película no existe.</p>
} @else if (recurso.error()) {
<p>No se pudo cargar la película.</p>
}
@if (seleccionada(); as film) {
<h2>{{ film.title }}</h2>
}

Y se usa de dos maneras:

<!-- Desde la lista: el clic ya llenó la signal -->
<app-film-detail />
// Desde el Router: el id llega por la URL
{ path: 'films/:id', component: FilmDetail },

El comportamiento de cada caso es este:

Situación Qué muestra ¿Pide al servidor?
Clic en la lista El elemento elegido y sus hijos, desde la signal No
Se llega por /films/:id El recurso que responde el servidor, también en los hijos Sí, una petición con ese id
Se llega por la ruta habiendo elegido otro elemento en la lista Nada del elemento anterior mientras carga; después, el de la ruta Sí
Cambia el id de la ruta El recurso nuevo Sí, una petición nueva
El servidor responde 404 El mensaje “no existe”, sin rastro del elemento anterior Sí, y se maneja el error

Tres ideas explican el código:

  • El effect convierte la respuesta en la selección. Con id, copia el recurso a la signal; sin id, no hace nada y la selección es la que dejó el clic de la lista.
  • Mientras carga (o si falla), la selección queda vacía. El effect llama a seleccionar(undefined) hasta que hay valor. Sin eso, la pantalla seguiría mostrando el elemento elegido antes.
  • hasValue() antes de value(). Con Angular 22, value() lanza una excepción cuando el recurso está en error. hasValue() protege la lectura (más en HttpResource y HttpClient).

Los tutoriales del Release 8 aplican esta teoría paso a paso: rutas y menú y el detalle de un recurso por ruta.

1. ¿Cuáles son las tres cosas que se espera de la librería de enrutamiento de cualquier framework?
2. ¿Qué tienen en común el enrutamiento del back (@GetMapping) y el del front?
3. ¿Para qué sirve <router-outlet />?
4. Un arreglo de rutas tiene la ruta '**' en primera posición. ¿Qué pasa al abrir /films/1?
5. ¿Qué diferencia hay entre routerLink y un href normal para un enlace interno?
6. En /films/tt0111161, ¿el enlace con routerLinkActive a /films queda activo?
7. ¿Qué tipo de valor recibe un input() conectado a un parámetro de ruta como :id?
8. Un detalle usa route.snapshot.paramMap.get('id'). El usuario pasa de /films/1 a /films/2 con un enlace. ¿Qué ve?
9. ¿Cuándo hace falta un detalle por ruta en vez de uno con el dato en memoria?
10. ¿Por qué se usa film.hasValue() antes de film.value() en el template de un detalle?
11. El usuario eligió una película en la lista y luego llega por la URL a otra. ¿Por qué el componente de detalle vacía la selección mientras la respuesta del servidor no llega?