angularrxjsangular-pipe

How can I continue refreshing an observable on button click when an observable error occurs in Angular?


I'm working on an Angular application where I fetch data from an API and display it using my custom state pipe and async pipe. There is a button to refresh the API call. However, if an error occurs, the observable stops emitting values and I can't refresh it anymore.

state.pipe.ts

import { Pipe, PipeTransform } from '@angular/core';
import { Observable, catchError, map, of, startWith } from 'rxjs';

type ObsState<T> = Observable<ObsStateAsync<T>>;

export type ObsStateAsync<T> = { loading: boolean; result?: T; error?: any };
@Pipe({
  name: 'state',
})
export class StatePipe implements PipeTransform {
  transform<T>(val: Observable<T>): ObsState<T> {
    return val.pipe(
      map((value: any) => ({ loading: false, result: value })),
      startWith({ loading: true }),
      catchError((error) => of({ loading: false, error }))
    );
  }
}
@Component({
  selector: 'app-root',
  template: `
    <button (click)="onRefresh()">Refresh</button>
    @let breedState = breeds$ | state | async;

    @if(breedState?.loading){
      Fetching ...
    }

    @if(breedState?.error){
      Error
    }

    @if(breedState?.result){
      @for(breed of breedState?.result;track $index){
        <div> 
          {{ breed.attributes.name }}
        </div>
      }
    }
  `,
  imports: [StatePipe, AsyncPipe],
})
export class App {
  private readonly httpClient = inject(HttpClient);

  refresh = new BehaviorSubject<void>(undefined);

  getBreed$ = this.httpClient
    .get('https://dogapi.dog/api/v2/breeds') // Modify API endpoint to create an error
    .pipe(map((res: any) => res.data));

  breeds$ = this.refresh.asObservable().pipe(switchMap(() => this.getBreed$));

  onRefresh(): void {
    this.refresh.next();
  }
}

And I don't wanna do the catchError(()=>of([])) thing because the error needs to be displayed on the UI.

Stackblitz to my problem


Solution

  • You have to place the catchError inside the pipe of the http observable, so I suggest a rxjs reusable pipe operator stateTransform which basically does the same thing, but is inside the pipe of the http.get.

    type ObsState<T> = Observable<ObsStateAsync<T>>;
    
    export type ObsStateAsync<T> = { loading: boolean; result?: T; error?: any };
    
    function stateTransform<T>(source$: Observable<T>): ObsState<T> {
      return source$.pipe(
        startWith({ loading: true }),
        map((value: any) => ({ loading: false, result: value })),
        catchError((error) => of({ loading: false, error }))
      );
    }
    

    You just need to add this reusable operator below the map transform.

    getBreed$ = this.httpClient
      .get<BreedResult>('https://dogapi.dog/api/v2/breedss') //modify api endpoint to create error
      .pipe(
        map((res: BreedResult) => res.data),
        stateTransform
      );
    

    Full Code:

    import { Component, inject } from '@angular/core';
    import { bootstrapApplication } from '@angular/platform-browser';
    import { HttpClient, provideHttpClient } from '@angular/common/http';
    import { StatePipe } from './state.pipe';
    import { AsyncPipe } from '@angular/common';
    import {
      BehaviorSubject,
      map,
      switchMap,
      Observable,
      startWith,
      catchError,
      of,
    } from 'rxjs';
    
    export interface BreedResult {
      data: Array<any>;
    }
    
    @Component({
      selector: 'app-root',
      template: `
      <button (click)="onRefresh()">Refresh</button>
      @let breedState = breeds$ | async;
    
      @if(breedState?.loading){
        Fetching ...
      } @else if(breedState?.error){
        Error
      }  @else if(breedState?.result){
        @for(breed of breedState?.result;track $index){
          <div> 
          {{breed.attributes.name}}
        </div>
        }
      }
      `,
      imports: [StatePipe, AsyncPipe],
    })
    export class App {
      private readonly httpClient = inject(HttpClient);
    
      refresh = new BehaviorSubject<void>(undefined);
    
      getBreed$ = this.httpClient
        .get<BreedResult>('https://dogapi.dog/api/v2/breedss') //modify api endpoint to create error
        .pipe(
          map((res: BreedResult) => res.data),
          stateTransform
        );
    
      breeds$ = this.refresh.asObservable().pipe(switchMap(() => this.getBreed$));
    
      onRefresh(): void {
        this.refresh.next();
      }
    }
    
    bootstrapApplication(App, { providers: [provideHttpClient()] });
    
    type ObsState<T> = Observable<ObsStateAsync<T>>;
    
    export type ObsStateAsync<T> = { loading: boolean; result?: T; error?: any };
    
    function stateTransform<T>(source$: Observable<T>): ObsState<T> {
      return source$.pipe(
        startWith({ loading: true }),
        map((value: any) => ({ loading: false, result: value })),
        catchError((error) => of({ loading: false, error }))
      );
    }
    

    Stackblitz Demo