Observables (rxjs)

<< Peticiones HTTP Pipe async >>

Ejemplo de petición GET

En el servicio haremos una petición GET a la url del servidor que devuelve el array de productos. La clase HttpClient no trabaja con promesas como la función fetch de JavaScript, sino con Observables. Para ello, utiliza por debajo la librería rxjs. Por ahora no nos centraremos en profundizar sobre este tipo de objetos que son extremadamente versátiles y potentes para trabajar con datos asíncronos, sino que nos limitaremos a lo básico para usarlos en peticiones http.

Para transformar el valor que nos devuelve el servidor (extraer de la respuesta el array de productos), utilizaremos la función map. Esto sería más o menos equivalente a usar el método then en una promesa para hacer lo mismo.

//...
import { Observable, map } from 'rxjs';
import { ProductsResponse } from '../interfaces/responses';

@Injectable({
  providedIn: 'root',
})
export class ProductsService {
  #productsUrl = 'http://miservidor.com/products';
  #http = inject(HttpClient);

  getProducts(): Observable<Product[]> {
    return this.#http
      .get<ProductsResponse>(`${this.#productsUrl}`)
      .pipe(map((resp) => resp.products));
  }
}

Las funciones que procesan los datos que devuelve un observable deben ir dentro del método pipe. Se ejecutan en el orden establecido y cada función toma como valor lo que devuelve la anterior.

Las diferencias entre observables y promesas son las siguientes:

  • Las promesas devuelven un único valor mientras que un observable puede desde no emitir ningún valor, a emitir varios a lo largo del tiempo.
  • Las promesas no se pueden cancelar una vez iniciadas, mientras que los observables sí.
  • Las promesas están limitadas a usar then y catch para procesar el valor, mientras que la librería rxjs tiene múltiples funciones diferentes para procesar y combinar observables.

Además de para gestionar peticiones Http, los observables son adecuados para gestionar eventos (múltiples emisiones de valor a lo largo del tiempo), o para su uso con protocolos tipo Websockets donde hay una conexión abierta a través de la cual se van recibiendo múltiples mensajes a lo largo del tiempo (Ej: librería socket.io).

Suscripción a un observable

En el componente products-page, debemos suscribirnos al observable con el método subscribe para obtener el resultado final y asignárselo al array de productos que gestiona el componente. Angular detectará que ha cambiado el array y mostrará los nuevos products obtenidos automáticamente.

export class ProductsPageComponent {
  //...
  constructor() {
    this.#productsService
      .getProducts()
      .subscribe(products => this.products.set(products));
  }
  //...
}

Al contrario que las promesas, que empiezan a ejecutarse cuando son creadas, los observables no se ejecutan hasta que nos suscribimos a ellos. Al suscribirse a un observable recibimos el dato resultante de la cadena de funciones que se han ejecutado dentro del método pipe.

Un observable admite varias subscripciones al mismo en diferentes partes del código y en diferentes momentos en el tiemplo. El comportamiento frente a nuevas suscripciones dependerá del tipo de observable:

  • Cold Observable: Cada subscripción al observable genera una ejecución del observable desde cero. Este es el caso de los observables que devuelven las peticiones utilizando el objeto HttpClient de Angular. Si nos subscribiéramos 2 veces, se realizarían 2 peticiones al servidor devolviendo cada petición su correpondiente resultado. Sería como una plataforma de vídeos donde cada visualización genera una reproducción del vídeo desde el principio.
  • Hot Observable: Generalmente son observables que emiten diferentes valores a lo largo del tiempo. Diferentes subscripciones no generan la reejecución del observable. Este va emitiendo valores y todas las subscripciones abiertas reciben el mismo valor al mismo tiempo. Sería como una plataforma de streaming en tiempo real donde solo puedes ver el vídeo en directo y todos los usuarios conectados están viendo lo mismo. Este tipo de observables se crean con clases como SubjectReplaySubject o BehaviorSubject.   

Si queremos detectar un posible error del observable (por ejemplo, una respuesta HTTP de error) y actuar en consecuencia, en el método subscribe, podríamos crear una función para manejar dicho error. En este caso, en lugar de una única función, debemos pasar un objeto que puede tener hasta 3 propiedades, que son funciones, para manejar distintas situaciones:

  • next → Función que recibe el valor que emite el observable cuando todo va bien.
  • error → Función que se ejecuta cuando hay un error
  • complete → Función que se ejecuta cuando el observable termina de emitir valores siempre que no haya habido errores. Esta función tiene sentido para observables que emiten varios valores a lo largo del tiempo, y con llamadas http no sería el caso.
export class ProductsPageComponent {
  //...
  constructor() {
    this.#productsService.getProducts().subscribe({
      next: (products) => this.products.set(products),
      error: (error) => console.error(error),
    });
  }
  //...
}

Si nuestra intención es que se ejecute alguna acción cuando se complete el observable (haya habido error o no), podemos utilizar el método add después de la subscripción. Por ejemplo, ocultar una animación de carga.

export class EjemploComponent {
    ejemploMetodo() {
        // Mostramos animación de carga
        this.#ejemploService
          .metodoHttp()
          .subscribe(/* Gestionamos respuesta/error */)
          .add(/* Cancelaríamos animación de carga */)
    }
}

Cancelar suscripción

En una aplicación Angular, especialmente si tiene varias secciones o páginas, o simplemente por las estructuras @if, @for, o @switch, hay componentes que se van creando y destruyendo en la aplicación (AppComponent sería una excepción al ser la raíz). Si en alguno de estos componentes hay una subscripción abierta a un observable, esta debería cancelarse en algún momento, por lo menos cuando el componente sea destruido.

Esto es sobre todo válido cuando la subscripción es a un observable que va emitiendo diferentes valores a lo largo del tiempo, y tarda bastante en completarse o se ejecuta de forma indefinida. Pero, ¿qué ocurre con los observables que generan las llamadas realizadas con HttpClient?. Estos se completan siempre en un tiempo razonable, bien sea cuando el servidor responde o ocurre un error en la comunicación. Cuando un observable termina, la suscripción es automáticamente cancelada.

Aún en estos casos, la recomendación es siempre cancelar la suscripción, sobre todo por una razón: Si el servidor tarda un poco en responder y el componente se destruye, aún así se ejecutará el método suscribe, pudiendo causar efectos no deseados al ejecutar código de un componente que ya no existe (y que a lo mejor tenía que notificar algo al componente padre o comunicarse con un servicio, por ejemplo).

Podemos cancelar la suscripción de 2 formas:

  • El método subscribe devuelve una referencia al objeto de la suscripción que podemos utilizar más adelante para cancelarla en cualquier momento. En nuestro caso la cancelaremos en el método ngOnDestroy del ciclo de vida. Si el observable se hubiera completado no pasaría nada.
  • Utilizando el nuevo operador takeUntilDestroyed(). Pasándoselo al observable dentro de un método pipe() antes de la suscripción, automáticamente detecta cuando el componente se destruye y cancela la suscripción. Esta solución es más limpia.
export class ProductsPageComponent implements OnDestroy {
  //...
  productsSubscription: Subscription;

  constructor() {
    this.productsSubscription = this.#productsService
      .getProducts()
      .subscribe((products) => this.products.set(products));
  }

  ngOnDestroy(): void {
    this.productsSubscription.unsubscribe();   // Cancelamos si el componente se destruye
  }
  //...
}

Otros ejemplos de peticiones

Cambiar puntuación de un producto

Las llamadas a servicios web utilizando get y delete no requieren de datos adicionales más allá de la url. Sin embargo, las llamadas del tipo post y put generalmente sí lo hacen. Para ver un ejemplo de cómo enviar esos datos adicionales, vamos a hacer una llamada de tipo put para modificar la puntuación de un producto.

export class ProductsService {
  //...
  changeRating(idProduct: number, rating: number): Observable<void> {
    return this.#http.put<void>(`${this.#productsUrl}/${idProduct}/rating`, {
      rating: rating,
    });
  }
}

Como se puede ver, el servidor no va a devolver datos en la respuesta (void), o si fuera así lo ignoraremos, sin embargo debemos devolver siempre el observable para poder crear una subscripción en el componente y así saber cuando el servidor ha modificado correctamente la puntuación (además de detectar errores).

Desde el componente product-item generamos esta llamada al servidor y nos suscribirmos a la respuesta. Para detectar cuando la puntuación cambia, podemos utilizar el evento de salida ratingChange, dejando rating solo para la entrada de valor. Esto lo vimos en la sección de parámetros de entrada/salida (model).

<!-- ... -->
<div class="col my-auto">
  <star-rating
    [rating]="product().rating"
    (ratingChange)="changeRating($event)"
  />
</div>
<!-- ... -->

Esto funcionaría correctamente. Sin embargo, sería recomendable devolver la puntuación al estado anterior en caso de producirse un error al cambiar la puntación. Esto trae algunas complicaciones en componentes con la estrategia OnPush o aplicaciones zoneless, que tiene solución. 

Cuando se devuelve la puntuación a partir de un error en el método subscribe, Angular no detectará los cambios ya que no es un evento (ratingChange) el que ha generado ese cambio, sino un observable, por lo que no veríamos que la puntuación vuelve a su valor original. Esto se soluciona llamando al método markForCheck del servicio ChangeDetectorRef, que marca el componente actual y sus descendientes como dirty para que Angular detecte los cambios.

Para que funcione esta detección de cambios, se debe primero actualizar la puntuación, antes de llamar al servidor. Esto se debe a que la señal de entrada, al ser de tipo model, se actualiza en el componente star-rating primero. Por ello debemos hacer lo propio en el componente product-item, y solo generar otro cambio en caso de error, y así que que Angular lo detecte y restaure la puntuación al estado anterior.

export class ProductItemComponent {
  //...
  #changeDetector = inject(ChangeDetectorRef);
  #destroyRef = inject(DestroyRef);
  //...
  changeRating(rating: number) {
    const oldRating = this.product().rating; // Guardamos puntuación actual
    this.product().rating = rating; // Modificamos antes de la llamada
    this.#productsService
      .changeRating(this.product().id!, rating)
      .pipe(takeUntilDestroyed(this.#destroyRef))
      .subscribe({
        error: () => { // Ha habido un error (puntuación no cambiada en el servidor)
          this.product().rating = oldRating; // Restauramos puntuación
          this.#changeDetector.markForCheck(); // Detectar cambio
        },
      });
  }
}

La función takeUntilDestroy, cuando no se crea en los atributos de la clase o en el constructor (cualquier método), necesita que se le pase el servicio DestroyRef por parámetro.

Otra opción sería utilizar 2 parámetros en star-rating (input y ouput), y así solo actualizar la puntuación cuando el servidor responda correctamente, y dejarla como estaba en caso de error. Sin embargo, si el servidor tardase en responder, quedaría raro que el usuario, al sacar el ratón del componente star-rating viera que el cambio parece no haber tenido efecto, para que unos momentos más tarde aparezca la puntuación indicada. Por ello, es buena estrategia en este caso, actualizarla de antemano en todo caso, y devolverla al estado anterior en caso de error.  

Insertar un producto

Para enviar un producto por POST al servidor y que lo inserte en su base de datos, primero creamos el método correspondiente en el servicio.

export class ProductsService {
  //..
  insertProduct(product: Product): Observable<Product> {
    return this.#http
      .post<SingleProductResponse>(this.#productsUrl, product)
      .pipe(map((resp) => resp.product));
  }
  //..
}

En el componente product-form, ahora simplemente tenemos que esperar a que el servidor nos responda antes de emitir el producto al componente padre y resetear el formulario. Emitiremos el producto que nos devuelve el servidor (ya tendrá una id asignada)

export class ProductFormComponent {
  //...
  #productsService = inject(ProductsService);
  #destroyRef = inject(DestroyRef);
  //...
    addProduct(productForm: NgForm) {
    this.#productsService
      .insertProduct(this.newProduct)
      .pipe(takeUntilDestroyed(this.#destroyRef))
      .subscribe((product) => {
        this.add.emit(product); // Emitimos el producto (con id) devuelto por el servidor
        productForm.resetForm(); // Reseteamos los campos de newProduct
        this.newProduct.imageUrl = ''; // La imagen también (no está vinculada al formulario)
      });
  }
}

Por si el servidor tardase en responder, sería una buena idea crear un valor booleano y utilizarlo para deshabilitar el botón de envío (propiedad disabled) hasta que el servidor responda (o haya un error).

En el componente products-page, ya no nos hará falta generar la id a mano. Utilizaremos el producto que nos devuelve el servidor con id.

export class ProductsPageComponent {
  //...
  addProduct(product: Product) {
    // Quitamos la generación manual de la id
    this.products.update((products) => [...products, product]);
  }
  //...
}

Borrar un producto

Primero crearemos el método para hacer la llamada correspondiente en ProductsService. Como el servidor devuelve una respuesta vacía (NO_CONTENT: 204), lo indicamos con el tipo void.

export class ProductsService {
  //...
  deleteProduct(id: number): Observable<void> {
    return this.#http.delete<void>(`${this.#productsUrl}/${id}`);
  }
  //...
}

En el componente product-item, avisamos al componente padre de que el producto ha sido eliminado cuando el servidor nos lo confirma.

export class ProductItemComponent {
  //...
  deleteProduct() {
    this.#productsService
      .deleteProduct(this.product().id!)
      .pipe(takeUntilDestroyed(#destroyRef))
      .subscribe(() => this.deleted.emit());
  }
  //...
}

<< Peticiones HTTP Pipe async >>