22

just as the title says, I want to embrace the power of rxjs Observables.

What I do now:

// dataview.html
<div *ngIf="isLoading">Loading data...div>
<ul *ngIf="!isLoading">
    <li *ngFor="let d of data">{{ d.value }}</li>
</ul>


// dataview.ts

data: any[] = [];
isLoading: boolean = false;

getData() {

this.isLoading = true;
this._api.getData().subscribe(
        data => {
            this.data = data;
            this.isLoading = false;
        },
        error => {
            this.error = error;
            this.isLoading = false;
        });
}

What I want to do:

1. Use async pipe in my template

  1. Make data an Observable array

  2. Still display loading information for the user

I'm a big fan of clean code, so how can this be done nicely using rxjs and Angular 2?

Maximilian Riegler
  • 22,720
  • 4
  • 62
  • 71
Alexander Ciesielski
  • 10,506
  • 5
  • 45
  • 66
  • Possible duplicate of [Show loading screen when navigating between routes in Angular 2](http://stackoverflow.com/questions/37069609/show-loading-screen-when-navigating-between-routes-in-angular-2) – Ankit Singh Jul 08 '16 at 08:28
  • 2
    Sorry, but it's a different kind of question. I'm looking for a clean solution to a general problem of rxjs Observables. The solution you provided in the question you linked is basically the same way I am doing it right now. Specifically, I want to leverage the power of the async pipe. – Alexander Ciesielski Jul 08 '16 at 08:37
  • @AlexanderCiesielski Did you ever get a better solution? I need something similar - like an observable that emits a tuple like `{ data: null, isLoading: true }` - but the general case - and a standard if there is one – Simon_Weaver Jan 05 '18 at 05:30
  • See also https://medium.com/@a.yurich.zuev/angular-show-loading-indicator-when-obs-async-is-not-yet-resolved-9d8e5497dd8 – yurzui Aug 28 '19 at 19:24

7 Answers7

18

This is how I do it. Also i use $ at the and of the variable name to remind me that it is a stream.

// dataview.html
<div *ngIf="isLoading$ | async">Loading data...</div>
<ul *ngIf="!(isLoading$ | async)">
    <li *ngFor="let d of data">{{ d.value }}</li>
</ul>


// dataview.ts

data: any[] = [];
isLoading$: BehaviorSubject<boolean> = new BehaviorSubject(false);

getData() {

this.isLoading$.next(true);

this._api.getData().subscribe(
        data => {
            this.data = data;
        },
        error => {
            this.error = error;
        },
        complete => {
            this.isLoading$.next(false);
        });
}
Joel Azevedo
  • 616
  • 1
  • 7
  • 26
Kliment
  • 2,250
  • 3
  • 18
  • 32
  • I was hoping to do just `dataObservable = this._api.getData();` and then the async pipe subscribes to that. But maybe I am oversimplifying too much. – Alexander Ciesielski Jul 08 '16 at 20:25
  • Its not wise to use `this._api.getData()` in template because its only fires one subscribe and then completes. You can have another stream `data$` that you will use in the template – Kliment Jul 09 '16 at 11:38
  • 2
    Why is it a BehaviourSubject and not just a boolean? – Samantha Adrichem Dec 08 '17 at 10:09
  • @SamanthaAdrichem It is BehaviourSubject because this example is for using streams in templates with async pipe, because using streams to update your template will make the rendering of the view faster – Kliment Dec 09 '17 at 15:34
  • 2
    Bear in mind that when doing async, it will subscribe to the Observable. So in the code above will result in two api call to the server because there are two async pipe was using. To prevent it, using async as https://blog.angular-university.io/angular-reactive-templates/ – trungk18 Dec 18 '18 at 07:49
17

I Came up with the following:

export enum ObsStatus {
  SUCCESS = 'Success',
  ERROR = 'Error',
  LOADING = 'Loading',
}

export interface WrapObsWithStatus<T> {
  status: ObsStatus;
  value: T;
  error: Error;
}

export function wrapObsWithStatus<T>(obs: Observable<T>): Observable<WrapObsWithStatus<T>> {
  return obs.pipe(
    map(x => ({ status: ObsStatus.SUCCESS, value: x, error: null })),
    startWith({ status: ObsStatus.LOADING, value: null, error: null }),
    catchError((err: Error) => {
      return of({ status: ObsStatus.ERROR, value: null, error: err });
    })
  );
}

And then in your component:

TS

public ObsStatus: typeof ObsStatus = ObsStatus;

public obs$: Observable<WrapObsWithStatus<YOUR_TYPE_HERE>> = wrapObsWithStatus(this.myService.getObs());

HTML

<div *ngIf="obs$ | async as obs" [ngSwitch]="obs.status">
  <div *ngSwitchCase="ObsStatus.SUCCESS">
    Success! {{ obs.value }}
  </div>

  <div *ngSwitchCase="ObsStatus.ERROR">
    Error! {{ obs.error }}
  </div>

  <div *ngSwitchCase="ObsStatus.LOADING">
    Loading!
  </div>
</div>
maxime1992
  • 22,502
  • 10
  • 80
  • 121
  • Glad to hear that! Cheers – maxime1992 Dec 24 '18 at 08:19
  • Hi! Is it possible to expand this example for use by switchmap? In case of error, the observable closes and stops receiving events. – avechuche Oct 14 '20 at 23:40
  • There's nothing async in the code I made so I do not follow why you'd want to use a switchMap. It's simply using a map, which is some synchronous code so it's unrelated to switchMap, mergeMap, concatMap or any other async operator :) – maxime1992 Oct 15 '20 at 09:33
6

I did it by using the async pipe. But this approach still required you to catch it manually to handle the error. See here for more detail.

app.component.html

<div class="wrapper">
    <div class="form-group" *ngIf="pickupLocations$ | async as pickupLocations; else loading">    
        <ul class="dropdown-menu" *ngIf="pickupLocations.length">
            <li *ngFor="let location of pickupLocations">
                <strong>{{location.Key}}</strong>
            </li>
        </ul>
        <span *ngIf="!pickupLocations.length">There are no locations to display</span>
    </div>

    <ng-template #loading>
        <i class="fa fa-circle-o-notch fa-spin fa-3x fa-fw"></i>
        <span class="sr-only">Loading...</span>
    </ng-template>
</div>

app.component.ts

this.pickupLocations$ = this.apiService.getPickupLocations(storeId);
trungk18
  • 19,744
  • 8
  • 48
  • 83
1

This is my current best attempt for displaying search results.

I thought about extending Observable somehow to include an isLoading property - or returning a tuple but in the end a helper function (in my service) that returns a pair of observables seems to be the cleanest way. Like you I was looking for some 'magic' but I can't see any better way to do it than this.


So in this example I have a FormGroup (a standard reactive form) which contains search criteria:

{ email: string, name: string } 

I get the search criteria from the form's valueChanges observable when it changes.

Component Constructor

Note: The search isn't actually run until the criteria change, which is why this is in the constructor.

// get debounced data from search UI
var customerSearchCriteria = this.searchForm.valueChanges.debounceTime(1000);

// create a pair of observables using a service (data + loading state)
this.customers = this.customersService.searchCustomers(customerSearchCriteria);

// this.customers.data => an observable containing the search results array
// this.customers.isLoading => an observable for whether the search is running or not

Search Service

public searchCustomers(searchCriteria: Observable<CustomersSearch>):
                       { data: Observable<CustomerSearchResult[]>, 
                         isLoading: Observable<boolean> }
{
    // Observable to track loading state
    var isLoading$ = new BehaviorSubject(false);

    // Every time the search criteria changes run the search
    var results$ = searchCriteria
                    .distinctUntilChanged()
                    .switchMap(criteria =>
                    {
                        // update isLoading = true
                        isLoading$.next(true);

                        // run search
                        var search$ = this.client.search(new CustomersSearch(criteria)).shareReplay();

                        // when search complete set isLoading = false
                        search$.subscribe({ complete: () => isLoading$.next(false) });

                        return search$;
                    })
                    .shareReplay();

    return { data: results$, isLoading: isLoading$ };
}

Need to find some way to make this generic, but that's pretty easy. Also note that if you don't care about isLoading you simply do searchCustomers(criteria).data and then you're just getting to the data.

Edit: needed to add an extra ShareReply to prevent search firing twice.

Component HTML

Use both customers.data and customers.isLoading as observables as normal. Remember customers is just an object with two observable properties on it.

<div *ngIf="customers.isLoading | async">Loading data...</div>
<ul *ngIf="!(customers.isLoading | async)">
    <li *ngFor="let d of customers.data | async">{{ d.email }}</li>
</ul>

Also note that you need the async pipe for both observables. I realize that looks a little clumsy for the isLoading, I believe that it is faster to use an observable than a property anyway. There could be a refinement to this, but I'm not yet an expert but would certainly welcome improvements.

Simon_Weaver
  • 140,023
  • 84
  • 646
  • 689
  • If you use `
    ...
    ` then you don't have to subscribe to the observable again, and can use the resolved value stored in the `_customers` variable: `_customers.isLoading` and `_customers.data`.
    – Johan Aspeling Dec 06 '18 at 11:36
0

One way to do that without any member property could be evaluating the async observable results in the template: !(yourAsyncData$ | async) or !(yourAsyncData$ | async)?.length.

For instance: <p-dataView #dv [value]="bikes$ | async" [loading]="!(bikes$ | async)"> ... </p-dataview>

Alex Fuentes
  • 61
  • 2
  • 5
0

Perhaps this could work for you. This show the data when the observable exists and there is async data. Otherwise shows a loading template.

<ul *ngIf="data$ && (data$ | async);else loading">
    <li *ngFor="let d of data$ | async">{{ d.value }}</li>
</ul>
<ng-template #loading>Loading...</ng-template>

0

More elegant and reactive way of doing it with merge.

// dataview.html
<ul *ngIf="data$ | async as data; else loading">
    <li *ngFor="let d of data">{{ d.value }}</li>
</ul>

<ng-template #loading>
  <div >Loading data...</div>
</ng-template>

// dataview.ts
data$: Observable<any[] | null>;

getData(): void {
  const showLoading$: Observable<any[]> = of([]);
  const request$: Observable<any[] | null> = this._api.getData().pipe(
    map(data => data), // disable loading on successfull request
    catchError(() => of([])) // disable loading on error request
  );

  // 1. showLoading$ will show loading before the request is made
  // 2. request$ then request comes in with delay and tell Us if success = any[] or error = null
  //                      1.          2.
  this.data$ = merge(showLoading$, request$);
}
Chriss
  • 21
  • 3