Ir al contenido

Tutorial: listar datos remotos en Cards con httpResource

En este taller vamos a construir, paso a paso, una app Angular standalone que trae un catálogo de películas desde un Gist público de GitHub y lo muestra en tarjetas (Cards).


Al terminar este taller vas a ser capaz de:

  • Crear una aplicación Angular standalone, sin NgModule.
  • Explicar qué es un environment y por qué la URL de una API no debería vivir hardcodeada dentro de un componente.
  • Registrar HttpClient a nivel de aplicación con provideHttpClient().
  • Crear un componente standalone enfocado en una sola responsabilidad (mostrar un catálogo).
  • Hacer fetch de datos remotos con httpResource(), la API reactiva basada en signals, sin escribir subscribe() / unsubscribe() a mano.
  • Manejar los tres estados de una petición (cargando, error, datos) y renderizarlos en el template con la sintaxis @if / @for.

Prerrequisitos: Node.js y Angular CLI instalados, y conocimientos básicos de TypeScript (interfaces, tipos, clases).


2. Crear la aplicación Angular (standalone)

Sección titulada «2. Crear la aplicación Angular (standalone)»

Desde v17, Angular CLI genera proyectos standalone por defecto (sin AppModule). Crea el proyecto así:

Ventana de terminal
ng new filmsignals2 --style=css --routing=false --ssr=false
cd filmsignals2
  • --routing=false: no necesitamos rutas para este ejercicio.
  • --ssr=false: evita que el CLI configure Server-Side Rendering, que no usaremos aquí. Si el CLI te pregunta por SSR/IA en el asistente interactivo, responde “No”.

Revisa la estructura generada:

src/
├── index.html
├── main.ts
└── app/
├── app.component.ts
├── app.component.html
└── app.config.ts

app.component.html es el template que el CLI genera por defecto para AppComponent. Más adelante lo vamos a reemplazar por un template inline (no necesita HTML propio), así que puedes borrarlo cuando lleguemos ahí.

Dos archivos clave:

src/main.ts — el punto de entrada. Se hace bootstrap directo del componente raíz:

import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { AppComponent } from './app/app.component';
bootstrapApplication(AppComponent, appConfig)
.catch(err => console.error(err));

src/app/app.component.ts — el componente raíz declara sus dependencias en imports:

@Component({
selector: 'app-root',
standalone: true,
imports: [],
template: `Hola`, // lo reemplazaremos en el paso 5
})
export class AppComponent {}

Antes de tocar el componente, separamos la URL del Gist del código: así el componente no sabe “de dónde” vienen los datos, solo que existe una URL configurada en algún lado. Esto también permite tener valores distintos por entorno (desarrollo, producción) sin tocar el componente.

Creamos la carpeta environments/:

src/environments/environment.ts (usado en producción por defecto):

Cuando se tenga la URL del Gist, se reemplaza el valor acá en los environments.

export const environment = {
production: true,
gistUrl: 'https://gist.githubusercontent.com/<tu-usuario>/<id>/raw/.../films.json',
};

src/environments/environment.development.ts (para ng serve):

export const environment = {
production: false,
gistUrl: 'https://gist.githubusercontent.com/<tu-usuario>/<id>/raw/.../films.json',
};

💡 Cada estudiante puede crear su propio Gist con su propio JSON de películas (o cualquier otro dataset) y pegar aquí su URL.

Para que Angular sepa que debe usar environment.development.ts cuando corres en modo desarrollo, registra un fileReplacements en angular.json, dentro de la configuración development del target build:

"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true,
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.development.ts"
}
]
}

Con esto, en cualquier parte del código puedes hacer import { environment } from '../../environments/environment'; y el bundler resuelve el archivo correcto según la configuración de build.


httpResource() (que usaremos en el paso 6) necesita que HttpClient esté registrado en el árbol de inyección de la aplicación. En un proyecto standalone eso se hace con un provider function:

src/app/app.config.ts

import { ApplicationConfig } from '@angular/core';
import { provideHttpClient } from '@angular/common/http';
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(),
],
};

Genera el componente que va a mostrar el catálogo:

Ventana de terminal
ng generate component film-list

Esto crea src/app/film-list/ con su .ts, .html y .css, ya marcado como standalone: true por defecto.

Antes de programar el fetch, define el modelo de datos — el contrato que describe la forma de cada elemento que viene del Gist:

src/app/film.model.ts

export interface Film {
title: string;
year: string;
director: string;
imdbRating: string;
imdbID: string;
poster: string;
}

Y conecta el componente en la raíz de la app:

src/app/app.component.ts

import { Component } from '@angular/core';
import { FilmListComponent } from './film-list/film-list.component';
@Component({
selector: 'app-root',
standalone: true,
imports: [FilmListComponent],
template: `<app-film-list />`,
})
export class AppComponent {}

AppComponent no tiene lógica propia: su único trabajo es renderizar FilmListComponent. Toda la responsabilidad del catálogo vive en un solo lugar.


Ahora sí, el corazón del taller. httpResource() es una API (en developer preview) de @angular/common/http que envuelve un GET de HttpClient y devuelve un recurso reactivo basado en signals: no te suscribes a nada, simplemente lees sus signals en el template.

src/app/film-list/film-list.component.ts

import { Component } from '@angular/core';
import { httpResource } from '@angular/common/http';
import { Film } from '../film.model';
import { environment } from '../../environments/environment';
@Component({
selector: 'app-film-list',
standalone: true,
templateUrl: './film-list.component.html',
styleUrl: './film-list.component.css',
})
export class FilmListComponent {
filmsResource = httpResource<Film[]>(() => environment.gistUrl);
}

filmsResource expone tres signals que usaremos en el template:

Signal Qué significa
filmsResource.isLoading() true mientras la petición está en curso
filmsResource.error() Contiene el error si la petición falló
filmsResource.value() El Film[] recibido, una vez la petición resuelve

💡 Con httpResource(), todo el manejo de estado (carga, error, datos) viene resuelto por el propio recurso.

⚠️ httpResource() está en developer preview: es perfecta para aprender el patrón de signals + datos remotos, pero su API todavía puede cambiar en futuras versiones de Angular.


src/app/film-list/film-list.component.html

Lo construimos por partes. Cada bloque retoma la numeración justo donde terminó el anterior, así que las líneas que ves corresponden a su posición real en el archivo final.

Primero, el contenedor y el encabezado — Bootstrap puro, todavía sin nada de Angular:

<div class="container py-4">
<h2 class="mb-4">Catálogo de Películas</h2>

Después vienen los dos primeros estados de la petición: cargando y error.

@if (filmsResource.isLoading()) {
<p class="text-muted">Cargando películas…</p>
}
@if (filmsResource.error()) {
<div class="alert alert-danger">
No se pudo cargar el catálogo. Verifica la conexión.
</div>
}

@if es independiente por bloque: los tres @if de este template (líneas 5, 9 y 15) no son mutuamente excluyentes en el código — no hay un @else que los conecte — pero en la práctica solo uno tiene contenido a la vez, porque isLoading(), error() y value() no coexisten con datos reales al mismo tiempo.

El tercer estado, cuando la petición ya trajo datos, abre un @for sobre el arreglo de películas:

@if (filmsResource.value()) {
<div class="row row-cols-1 row-cols-sm-2 row-cols-md-3 row-cols-lg-4 g-4">
@for (film of filmsResource.value(); track film.imdbID) {

track film.imdbID es obligatorio en @for. Angular lo usa para saber qué elemento del DOM corresponde a qué elemento de la lista cuando esta cambia. Una buena clave de track es un identificador único y estable — nunca el índice si los datos se pueden reordenar o filtrar.

Dentro del @for, cada película se dibuja como una Card de Bootstrap:

<div class="col">
<div class="card h-100 shadow-sm film-card">
<img
class="card-img-top"
[src]="film.poster.replace('http://', 'https://')"
[alt]="film.title"
style="height: 260px; object-fit: cover;"
/>
<div class="card-body">
<h5 class="card-title">{{ film.title }}</h5>
<p class="card-text text-muted small">
{{ film.year }} &middot; {{ film.director }}
</p>
<span class="badge bg-warning text-dark">★ {{ film.imdbRating }}</span>
</div>
</div>
</div>
  • La normalización del poster (línea 22, film.poster.replace('http://', 'https://')) ocurre inline en el template: si la página corre sobre HTTPS, cargar una imagen HTTP el navegador la bloquea (contenido mixto).
  • Las clases card, row, badge, etc. son de Bootstrap, cargado por CDN en src/index.html — no es una dependencia npm en este ejemplo.

Y para terminar, las tres estructuras se cierran en el orden inverso al que se abrieron — @for, el <div class="row"> y el @if de value() — antes de cerrar el contenedor:

}
</div>
}
</div>

Con esto, al correr ng serve deberías ver el catálogo cargando, y luego las tarjetas con póster, título, año, director y rating de cada película.