Resource API

<< signals + rxjs Interceptores >>

Una de las nuevas características de Angular 19 es la introducción de la API Resource para trabajar con peticiones asíncronas (promesas u observables).

  • resource → Trabaja con promesas
  • rxResource → Trabaja con observables
  • httpResource → Recibe directamente la URL donde lanzar la petición GET. Utiliza HttpClient por debajo, por lo que también tienen efecto los interceptores en este tipo de peticiones.

Dentro de la función resource o rxResource debemos pasarle un objeto con una función loader que hará la carga de datos. Esta función devolverá una promesa u observable con los datos.

productsResource = resource({
  loader: () => fetch('URL productos') // Promesa
})

Con httpResource podemos indicar que la respuesta (por defecto en formato JSON) nos la devuelva en un formato diferente si esperamos recibir texto plano o contenido binario por ejemplo.

httpResource.text(() => ...); // returns a string in value()
httpResource.blob(() => ...); // returns a Blob object in value()
httpResource.arrayBuffer(() => ...); // returns an ArrayBuffer in value()

Acceder al valor interno

Para acceder al valor una vez está disponible, tenemos la señal interna value.

@for (product of productsResource.value(); track product.id) {
    <!-- ... -->
}

Estado de carga

Podemos comprobar el estado del recurso con la señal status, o si solo queremos saber si está realizando la petición o ya a terminado, directamente usamos isLoading (boolean). 

@if(productsResource.isLoading()) {
    <p>Cargando productos...<p>
} @else {
    <!-- Mostrar productos -->
}

Gestión de errores

Para comprobar si ha habido un error en la última petición realizada podemos consultarlo con la señal error.

@let error = productsResource.error();

@if(productsResource.isLoading()) {
    <p>Cargando productos...<p>
} @else if(error) {
    <p>Ocurrió un error {{ error | json }}</p>
} @else {
    <!-- Mostrar productos -->
}

Importante: No se puede acceder al valor de un resource cuando está en estado de error. Esto lanzaría una excepción parando la ejecución de la aplicación. En estos casos se debe primero consultar si hay valor antes de acceder con hasValue()

export class ProductDetail {
  //...
  #productsService = inject(ProductsService);
  productResource = this.#productsService.getProductResource(this.id);
	
  constructor() {
	effect(() => {
      // Check if there's a value before accessing (not in loading or error state) 
      const prop = this.productResource.hasValue() ? this.productResource.value() : null;
      // Other actions
	}
  //...
}

Recarga manual

Se puede forzar al recurso a volver a realizar la petición utilizando el método reload.

@let error = productsResource.error();

@if(productsResource.isLoading()) {
    <p>Cargando productos...<p>
} @else if(error) {
    <p>Ocurrió un error {{ error | json }}</p>
    <p><button (click)="productsResource.reload()">Reintentar</button></p>
} @else {
    <!-- Mostrar productos -->
}

Vinculación de parámetros

Parámetros en resource y httpResource

Por medio de una función request, podemos devolver una serie de valores a los que podremos acceder en la función loader. En la función request, las señales a las que accedamos crean depedencias (como usar computed o linkedSignal) y cada vez que cambia su valor se reevalúa la función y se relanza la petición recargando los datos.

//...
export class ProductDetail  {
  //...
  productResource = rxResource({
    params: () => this.id(), // Cada vez que cambia la id, carga un nuevo producto
    stream: ({params: id}) => this.#productsService.getProduct(id)
  });
  //...
}

Si necesitamos más de un parámetro, bastaría con devolver un objeto (que se recibiría en la propiedad request del loader).

export class EjemploComponent  {
  //...
  itemResource = rxResource({
    params: () => { search: this.search(), page: this.page() }, // Cada vez que cambia la id, carga un nuevo producto
    stream: ({params}) => this.#itemsService.getItems(params.search, params.page)
  });
  //...
}

Parámetros en httpResource

En el caso de httpResource, las signals que leamos en la función pasada por parámetro para construir la URL, automáticamente se vincularán y cada vez que cambien, se volverá a hacer la petición.

productResource = httpResource<SingleProductResponse>(
  () => `products/${this.id()}` // Queda vinculado a la signal id
)
productResource = httpResource<ProductsResponse>(
  () => {
    const urlSearchParams = new URLSearchParams({ search: this.search(), page: this.page() }); 
    return `products?${urlSearchParams.toString()}`;
  } 
)

Valor por defecto

Como segundo parámetro podemos pasar una serie de opciones a la función httpResource. Uno de ellos es defaultValue. Por defecto, cuando el resource está cargando datos del servidor, aunque antes hubieran datos previamente cargados, su valor permanece en estado undefined. Con esta opción podemos seleccionar un valor diferente por defecto y evitar la posibilidad de undefined en nuestro código al acceder al valor.

productsResource = httpResource<ProductsResponse>(
  () => 'URL productos',
  { defaultValue: { products: [] } }
)

Cancelando un resource 

Cuando cambia el valor de los parámetros vinculados, la petición actual, si estaba en curso, se cancela generando una nueva. Esto con observables (rxResource) se gestiona automáticamente. Sin embargo, al usar promesas (resource), le tenemos que pasar un objeto AbortSignal a la promesa para cancelarla. 

productResource = resource({
  request: () => this.id(), // Cada vez que cambia la id, carga un nuevo producto
  loader: ({request: id, abortSignal}) => 
    fetch(`URL de productos/${id}`, {
      signal: abortSignal,
    })
});

¿Donde crear resources? Component vs Service

Es cierto que si solo vamos a utilizar un resource en un componente de la aplicación, parece más cómodo ponerlo directamente ahí, pero estaríamos rompiendo el principio de reparto de responsabilidades. La lógica de las peticiones http debería estar gestionada por los servicios en Angular, por lo que generalmente los pondremos ahí, igual que el resto de peticiones HTTP (post, put, delete).

@Injectable()
export class ProductsService {
  // Resource compartido para la aplicación
  readonly productsResource = httpResource<ProductsResponse>(() => `products`, {
    defaultValue: { products: [] },
  });

  // Resource en base a su id (método factory -> resource personalizado)
  getProductIdResource(id: Signal<string>) {
    return httpResource<SingleProductResponse>(() => (
      id() ? `products/${id()}` : undefined // Cuando es undefined no lanza petición http
    ));
  }
}
@Component({ /* ... */ })
export class ProductsPage {
  readonly #productsService = inject(ProductsService);
  readonly productsResource = this.#productsService.productsResource;
  products = linkedSignal(() => this.productsResource.value().products);
  //...
}

Paginación usando resource

El objeto resource tiene un pequeño inconveniente: No mantiene los datos entre una petición y otra. Esto causa que si tenemos un sistema de paginación donde vamos cargando la siguiente página y concatenando el resultado a los datos anteriores, se vuelva un poco complejo de gestionar.

En este caso podríamos optar por no usar Resource API y centrarnos en la gestión de la paginación con observables directamente. Pero también podemos apoyarnos en un objeto de tipo linkedSignal derivado del valor del resource.

Lo que va a hacer el objeto linkedSignal es vincularse al valor del resource (propiedad source). Cada vez que cambie lanzará la función computation, donde recibe el valor actual (resp) y el valor anterior (previous). La lógica es la siguiente:

  • Si el valor (resp) es undefined, se queda con el valor anterior (o array vacío si no había)
  • Si la página es mayor a 1, concatena los productos recibidos al valor anterior (array de productos existente)
  • Si la página es 1, sustituye lo que tenía devolviendo el nuevo array.
productsResource = httpResource<ProductsResponse>(
  () => `products?${this.page()}`
);

products = linkedSignal<ProductsResponse | undefined, Course[]>({
  source: () => this.productsResource?.value(),
  computation: (resp, previous) => {
    if(!resp) return previous?.value ?? [];
    return this.page() > 1 && previous ? previous.value.concat(resp!.products) : resp?.products;
  }
});

<< signals + rxjs Interceptores >>