Enrutamiento y Angular Router
Qué es el enrutamiento
Sección titulada «Qué es el enrutamiento»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 |
Qué se espera de un router
Sección titulada «Qué se espera de un router»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:
- 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. - 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).
- 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 elegidaAdemá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ú.
Es el mismo concepto del back
Sección titulada «Es el mismo concepto del back»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” |
Cada framework trae su librería
Sección titulada «Cada framework trae su librería»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.
Las tres ideas en Angular
Sección titulada «Las tres ideas en Angular»| 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 /> |
Configurar el Router
Sección titulada «Configurar el Router»Antes de definir las rutas, hay que registrar el Router en la aplicación. Esto se hace en el archivo app.config.ts:
import { provideRouter, withComponentInputBinding } from '@angular/router';import { routes } from './app.routes';
export const appConfig: ApplicationConfig = { providers: [ provideHttpClient(), provideRouter(routes, withComponentInputBinding()), ],};routeses la tabla que dice qué componente corresponde a cada URL.provideRouter(routes)registra esa tabla en la aplicación, igual queprovideHttpClient()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.
El usuario abre la URL
Escribe la dirección o hace clic en un enlace.
/filmsDefinir las rutas
Sección titulada «Definir las rutas»Las rutas se definen en un arreglo de tipo Routes, que en esta aplicación vive en el archivo 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.
pathes el trozo de URL, sin la barra inicial.'films'responde a/films.redirectToenvía una URL a otra. La raíz (path: '') redirige afilms, 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 desdepath: ''sinpathMatchlanza el errorNG04014al 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.
Enlaces y menú
Sección titulada «Enlaces y menú»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.routerLinkno recarga la página;hrefsí. ConrouterLinkel 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 usanrouterLink.
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.
Parámetros de ruta
Sección titulada «Parámetros de ruta»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:
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 elidde la película sea un número. Con ids numéricos se convierte al declarar elinput:input.required({ transform: numberAttribute }). - Hay una alternativa, el servicio
ActivatedRoute. Se inyecta y se lee conroute.snapshot.paramMap.get('id'). Tiene una trampa: elsnapshotes una foto de la URL en el momento de crear el componente. Al navegar de/snapshot/1a/snapshot/2desde la misma pantalla, el componente se reutiliza y elsnapshotsigue diciendo1. Coninput()el valor se actualiza solo.
Navegar desde el código
Sección titulada «Navegar desde el código»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.
Dónde se muestra la vista del componente
Sección titulada «Dónde se muestra la vista del componente»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:
<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 importaRouterOutletpara poder usarlo.
Varias vistas a la vez: outlets con nombre
Sección titulada «Varias vistas a la vez: outlets con nombre»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.
<router-outlet name="arriba" /><router-outlet name="izquierda" /><router-outlet name="derecha" />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'] } }]).
La URL guarda el estado de todos
Sección titulada «La URL guarda el estado de todos»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.
Dos tipos de detalle
Sección titulada «Dos tipos de detalle»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:
- ¿La lista ya trae todo lo que el detalle necesita? Si sí, el dato en memoria basta.
- ¿Alguien debería poder abrir ese detalle con su propio enlace? Si sí, necesita una ruta.
- ¿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 mismo componente, dos formas de llamarlo
Sección titulada «Un mismo componente, dos formas de llamarlo»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 porinput(): el dato ya está en la signal compartidaseleccionada, 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 elidde 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.
Lo que llega no tiene la misma forma
Sección titulada «Lo que llega no tiene la misma forma»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 por ruta, completo
Sección titulada «El detalle por ruta, completo»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:
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:
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); }); }}@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
effectconvierte la respuesta en la selección. Conid, copia el recurso a la signal; sinid, 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
effectllama aseleccionar(undefined)hasta que hay valor. Sin eso, la pantalla seguiría mostrando el elemento elegido antes. hasValue()antes devalue(). 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.