Ir al contenido

Tutorial: El detalle de un recurso por ruta

Al terminar este tutorial vas a ser capaz de:

  • Recibir el id de una ruta en un componente con input() y withComponentInputBinding().
  • Pedir al back el recurso con ese id desde un método del servicio que devuelve un httpResource, sin pedir nada mientras no haya id.
  • Hacer que el mismo componente de detalle sirva para el detalle en memoria y para el detalle por ruta, con una sola fuente de lo que se muestra.
  • Mostrar los estados de carga, de error y de 404 del detalle.
  • Mostrar un álbum con sus canciones desde GET /albumes/{id}, porque la lista de artistas no las trae.
  • Explicar por qué en app.routes.ts la ruta artistas/nuevo va antes de artistas/:id.

Prerrequisitos: el tutorial de rutas y menú con el Router (ya tienes app.routes.ts, provideRouter y el menú) y un back con GET /artistas/{id} y GET /albumes/{id}, como quedó en el Release 7. La idea de fondo está en la teoría de Enrutamiento y Angular Router, en Dos tipos de detalle y en El detalle por ruta, completo: este tutorial la aplica y no la repite.


Hoy el detalle de un artista es el del Release 4: la lista trae los artistas, un clic guarda el elegido en una signal del servicio y ArtistDetalle lo muestra, sin pedir nada al back. Tiene dos límites que el Router permite resolver:

  • No tiene URL propia: no se puede enviar un enlace al detalle de un artista, y al recargar la página el detalle desaparece.
  • Los álbumes de un artista llegan sin canciones. Para mostrarlas hay que pedir el álbum al back con su id: GET /albumes/{id} responde el álbum con la lista de canciones.

Vas a agregar dos rutas, y cada una resuelve uno de esos límites:

URL Vista Qué pide al back
/artistas/:id ArtistDetalle GET /artistas/{id}
/albumes/:id AlbumDetalle (nuevo) GET /albumes/{id}

El detalle en memoria no desaparece: sigue funcionando al hacer clic en la lista. El componente ArtistDetalle pasa a atender las dos formas.


Dos cambios, uno en la configuración y otro en las rutas. Primero, el Router debe conectar los parámetros de la URL con los input() de los componentes. Se activa con withComponentInputBinding() en provideRouter:

app.config.ts
import { provideRouter, withComponentInputBinding } from '@angular/router';
// dentro de providers:
provideRouter(routes, withComponentInputBinding()),

Y en app.routes.ts agrega la ruta del detalle del artista, después de artistas/nuevo:

app.routes.ts
import { ArtistDetalle } from './artist-detalle/artist-detalle';
// dentro de routes, en este orden:
{ path: 'artistas', component: ArtistList, title: 'Artistas' },
{ path: 'artistas/nuevo', component: ArtistCreate, title: 'Nuevo artista' },
{ path: 'artistas/:id', component: ArtistDetalle, title: 'Artista' },

El orden importa. El Router recorre las rutas en el orden en que están escritas y se queda con la primera que coincide, y :id coincide con cualquier texto, incluso con nuevo. Se comprobó cambiando el orden: con artistas/:id antes de artistas/nuevo, abrir /artistas/nuevo ya no muestra el formulario; el Router toma nuevo como un id, pide GET /artistas/nuevo al back y la pantalla dice “No se pudo cargar el artista”. La regla: las rutas fijas van antes que las que tienen un parámetro.


El recurso se crea desde el servicio, que es el único que habla con la red. En artist.service.ts agrega un método que devuelve un httpResource y cambia seleccionar para que acepte undefined:

artist.service.ts
// Acepta undefined: el detalle por ruta vacía la selección mientras el recurso carga
// o si falla, para que no se vea el artista elegido antes.
seleccionar(artista: Artista | undefined): void {
this._artistaSeleccionado.set(artista);
}
// El detalle por ruta: GET /artistas/{id}. Con una URL `undefined` (no hay id en la
// ruta) httpResource no hace la petición.
artistaResource(id: () => string | undefined): HttpResourceRef<Artista | undefined> {
return httpResource<Artista>(() => {
const valor: string | undefined = id();
return valor ? `${environment.ARTISTAS_URL}/${valor}` : undefined;
});
}

Fíjate en las piezas:

  • artistaResource recibe una función, no el id mismo. Así la URL se recalcula cada vez que el id cambia, como explica la teoría en Una URL que depende de un signal.
  • Devolver undefined en vez de una URL le dice a httpResource que no pida nada. Por eso el mismo componente puede estar en la lista, donde no hay id, sin hacer ninguna petición.
  • El id es un string. El valor de un parámetro de ruta llega siempre como texto, aunque el id del back sea un número. Para armar la URL no importa.

El componente sigue mostrando lo que hay en la signal compartida del servicio. Lo nuevo es que, si la ruta le entrega un id, la respuesta del back llena esa misma signal. Todo lo que cuelga del detalle, como la lista de álbumes, sigue leyendo un solo lugar.

artist-detalle.ts
import { Component, Signal, computed, effect, inject, input } from '@angular/core';
import { HttpErrorResponse, HttpResourceRef } from '@angular/common/http';
import { RouterLink } from '@angular/router';
import { Artista } from '../album-list/album';
import { ArtistaService } from '../artist.service';
import { AlbumList } from '../album-list/album-list';
@Component({
imports: [AlbumList, RouterLink],
selector: 'app-artist-detalle',
styleUrl: './artist-detalle.css',
templateUrl: './artist-detalle.html',
})
export class ArtistDetalle {
private readonly artistaService: ArtistaService = inject(ArtistaService);
// Detalle por ruta: el Router entrega el id de /artistas/:id. Cuando el componente
// está dentro de la lista (detalle en memoria) no hay ruta y el id queda undefined.
readonly id = input<string>();
// Lo que se muestra sale siempre de la signal compartida: la llena el clic de la
// lista o, con id, la respuesta del servidor.
readonly artistaSeleccionado: Signal<Artista | undefined> = this.artistaService.artistaSeleccionado;
// Solo pide al servidor si hay id.
readonly recurso: HttpResourceRef<Artista | undefined> = this.artistaService.artistaResource(
(): string | undefined => this.id(),
);
readonly noExiste: Signal<boolean> = computed((): boolean => {
const error: unknown = this.recurso.error();
return error instanceof HttpErrorResponse && error.status === 404;
});
constructor() {
effect((): void => {
if (!this.id()) return; // sin id, la selección la llena la lista al hacer clic
this.artistaService.seleccionar(this.recurso.hasValue() ? this.recurso.value() : undefined);
});
}
}

Y la plantilla, con los estados de carga, de error y de 404 antes de la tarjeta:

artist-detalle.html
@if (recurso.isLoading()) {
<p>Cargando artista…</p>
}
@if (noExiste()) {
<p class="text-danger">Ese artista no existe.</p>
} @else if (recurso.error()) {
<p class="text-danger">No se pudo cargar el artista. Intente de nuevo.</p>
}
@if (artistaSeleccionado(); as artista) {
<div class="card">
<!-- la tarjeta del artista que ya tenías: foto, nombre, país, nacimiento y biografía -->
@if (!id()) {
<a [routerLink]="['/artistas', artista.id]">Abrir en su propia página</a>
}
</div>
}
<div class="mt-4">
<app-album-list />
</div>

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 artista que se eligió antes.
  • hasValue() antes de value(). En estado de error, value() lanza una excepción. Está explicado en Leer el valor con seguridad.

El enlace “Abrir en su propia página” solo se muestra cuando no hay id, es decir, en el detalle en memoria de la lista: dentro de la ruta apuntaría a donde ya estás.


Ya hay un enlace en el detalle en memoria. La otra forma habitual de llegar a un artista es desde otra pantalla: en Premios, el nombre de cada ganador puede llevar a su ficha. En premio-detalle.ts importa RouterLink y en la plantilla envuelve el nombre del ganador:

premio-detalle.html
<a [routerLink]="['/artistas', ganador.artistaId]"><strong>{{ ganador.artistaNombre }}</strong></a>

['/artistas', ganador.artistaId] arma la URL /artistas/3: cada elemento del arreglo es un segmento, y el id se agrega solo.


Con el back y el front andando, prueba estas situaciones. Cada una se comprobó en un navegador con un back real:

Situación Qué muestra ¿Pide al back?
Clic en un artista de la lista El artista y sus álbumes, desde la signal. La URL sigue siendo /artistas. No
Clic en “Abrir en su propia página” /artistas/1: el artista y sus álbumes Sí, GET /artistas/1
Abrir /artistas/2 escribiendo la URL El artista 2 Sí, una petición con ese id
Cambiar la URL de /artistas/1 a /artistas/2 sin recargar El artista 2, en el mismo componente Sí, una petición nueva
Llegar a /artistas/3 con una respuesta lenta, habiendo elegido otro artista en la lista “Cargando artista…” y ninguna tarjeta del artista anterior; al llegar la respuesta, el artista 3 Sí
/artistas/9999 “Ese artista no existe.” Sí, responde 404
/artistas/nuevo El formulario de creación No
Clic en un ganador desde Premios La ficha de ese artista Sí

Los álbumes que trae un artista no llevan canciones: las canciones solo vienen en GET /albumes/{id}. Por eso el detalle del álbum necesita ir al back por su id, y es el caso natural para una ruta.

El modelo. El back no manda genero, letra ni fechaLanzamiento en las canciones de ese endpoint, así que en album.ts pasan a opcionales. Si los dejas obligatorios, el modelo exige datos que nunca llegan:

album.ts
export interface Cancion {
id: number;
titulo: string;
duracion: string;
// El back no los manda en las canciones de GET /albumes/{id}: por eso son opcionales.
genero?: string;
fotoPortadaUrl?: string;
letra?: string;
fechaLanzamiento?: string;
}

El servicio gana un segundo método, igual al del artista pero para el álbum:

artist.service.ts
// GET /albumes/{id}: el álbum con sus canciones (AlbumDTOCompleto).
albumResource(id: () => string | undefined): HttpResourceRef<AlbumCompleto | undefined> {
return httpResource<AlbumCompleto>(() => {
const valor: string | undefined = id();
return valor ? `${environment.ALBUMES_URL}/${valor}` : undefined;
});
}

El componente. Crea album-detalle/album-detalle.ts. Aquí no hay detalle en memoria que conservar, así que no hace falta la signal compartida ni el effect: el componente muestra directamente lo que responde el recurso.

album-detalle.ts
import { Component, Signal, computed, inject, input } from '@angular/core';
import { HttpErrorResponse, HttpResourceRef } from '@angular/common/http';
import { RouterLink } from '@angular/router';
import { AlbumCompleto, Cancion } from '../album-list/album';
import { ArtistaService } from '../artist.service';
@Component({
imports: [RouterLink],
selector: 'app-album-detalle',
templateUrl: './album-detalle.html',
})
export class AlbumDetalle {
private readonly artistaService: ArtistaService = inject(ArtistaService);
// El nombre del input debe ser igual al del parámetro de la ruta (:id).
readonly id = input<string>();
// GET /albumes/{id}: el álbum con sus canciones. No pide nada mientras no haya id.
readonly recurso: HttpResourceRef<AlbumCompleto | undefined> = this.artistaService.albumResource(
(): string | undefined => this.id(),
);
// hasValue() antes de value(): en estado de error value() lanza una excepción.
readonly album: Signal<AlbumCompleto | undefined> = computed((): AlbumCompleto | undefined =>
this.recurso.hasValue() ? this.recurso.value() : undefined,
);
readonly canciones: Signal<Cancion[]> = computed(
(): Cancion[] => this.album()?.canciones ?? [],
);
readonly noExiste: Signal<boolean> = computed((): boolean => {
const error: unknown = this.recurso.error();
return error instanceof HttpErrorResponse && error.status === 404;
});
}
album-detalle.html
<div class="container py-4">
<a routerLink="/artistas">&larr; Volver a los artistas</a>
@if (recurso.isLoading()) {
<p class="mt-3">Cargando el álbum…</p>
}
@if (noExiste()) {
<p class="mt-3 text-danger">Ese álbum no existe.</p>
} @else if (recurso.error()) {
<p class="mt-3 text-danger">No se pudo cargar el álbum. Intente de nuevo.</p>
}
@if (album(); as album) {
<h1 class="mt-3">{{ album.titulo }}</h1>
<p class="text-muted">Lanzado el {{ album.fechaLanzamiento }}</p>
<h2 class="h4 mb-3">Canciones</h2>
<ul class="list-group">
@for (cancion of canciones(); track cancion.id) {
<li class="list-group-item d-flex justify-content-between align-items-center">
<span>
<strong>{{ cancion.titulo }}</strong>
@if (cancion.genero) {
<br />
<small class="text-muted">{{ cancion.genero }}</small>
}
</span>
<span class="text-muted">{{ cancion.duracion }}</span>
</li>
} @empty {
<li class="list-group-item text-muted">Este álbum no tiene canciones.</li>
}
</ul>
}
</div>

La ruta y el enlace. Registra la ruta en app.routes.ts y haz que cada tarjeta de AlbumList sea un enlace a ella:

app.routes.ts
import { AlbumDetalle } from './album-detalle/album-detalle';
{ path: 'albumes/:id', component: AlbumDetalle, title: 'Álbum' },
album-list.html
<a class="card h-100 text-decoration-none" [routerLink]="['/albumes', album.id]">
<!-- imagen, título y fecha del álbum, como antes -->
</a>

AlbumList debe importar RouterLink. Ya no necesita el componente de canciones que mostraba al lado: SongList, su plantilla y lo que el servicio guardaba para seleccionar un álbum (seleccionarAlbum y el recurso del álbum seleccionado) se reemplazan por esta ruta, así que puedes borrarlos.

Para comprobarlo, entra a /artistas/1 y haz clic en un álbum: la URL pasa a /albumes/1, aparece el título y la lista de canciones, con una sola petición GET /albumes/1. Recargar la página conserva la vista. /albumes/9999 muestra “Ese álbum no existe.”


  • El detalle por ruta aparece vacío y no hay errores. El input() no se llama igual que el parámetro de la ruta, o falta withComponentInputBinding() en provideRouter. Sin una de las dos cosas, el id queda undefined.
  • /artistas/nuevo muestra “No se pudo cargar el artista”. La ruta :id está antes de nuevo. Las rutas fijas van primero.
  • Se ve el artista anterior mientras carga el nuevo. Falta el effect, o no vacía la selección mientras no hay valor (sección 5).
  • Una excepción al leer value() cuando el back responde un error. Se leyó value() sin preguntar antes hasValue().
  • El 404 se muestra como un error genérico. No se distinguió el status 404 en noExiste: un error de red y un recurso que no existe necesitan mensajes distintos.
  • Las canciones no aparecen. Se pidió el artista pero no el álbum: la lista de artistas trae los álbumes sin canciones, y solo GET /albumes/{id} las devuelve.

Se puede pedir la estructura de un componente de detalle por ruta: el input(), el recurso y la plantilla con los estados. Dale el nombre de tu entidad, la ruta y el endpoint.

Lo que debes revisar tú:

  • Que el input() se llame igual que el parámetro de la ruta, y que la ruta tenga withComponentInputBinding().
  • Que se pregunte hasValue() antes de leer value().
  • Que el orden de las rutas ponga las fijas antes de las que tienen parámetro.
  • Que el 404 tenga su propio mensaje.
  • Que no use ActivatedRoute con snapshot para leer el id: es una foto de la URL al crear el componente y no se entera si el id cambia (la teoría lo explica).

Al final de este tutorial tienes:

  • withComponentInputBinding() y las rutas artistas/:id y albumes/:id, con las fijas antes que las de parámetro.
  • Un ArtistDetalle que atiende el detalle en memoria y el de ruta con una sola fuente de lo que se muestra, y maneja carga, error y 404.
  • Un AlbumDetalle que muestra el álbum con sus canciones desde GET /albumes/{id}.
  • Enlaces a esos detalles desde la lista de artistas, el detalle y Premios.

Lo que viene después, siguiendo el orden de Release 8:

  • Los componentes que asocian entidades, con un selector que usa las opciones del back.