Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Using Observables, show loading indicator after delay but cancel if loading is completed in time?

In my customer-detail component I have the following code that achieves what I'm after but not in the reactive/observable way I think might be possible.

Instead of wrapping this.isLoading = true; in an if statement, is there a way to do this using reactive programming techniques? Perhaps by cancelling/dropping the delayed observable if the customer is retrieved first? Or, am I going about this the wrong way?

export class CustomerDetailComponent implements OnInit {

  customer: Customer;
  errorMessage: string;
  isLoading: boolean;

  constructor(
    private customerService: CustomerService,
    private route: ActivatedRoute,
    private router: Router,
    private location: Location
  ) { }

  ngOnInit() { 
    let idParam = this.route.params
      .distinctUntilChanged(params => params['id']);

    idParam.subscribe(params => 
    {
      this.errorMessage = ''; 
    });

    idParam.delay(300).subscribe(params => 
    {
      if (!(this.customer && this.customer.id == params['id']))
        this.isLoading = true;
    });

    idParam.switchMap((params: Params) => this.customerService.getCustomer(params['id']))
      .subscribe(customer => 
      { 
        this.customer = customer; 
        this.isLoading = false;
      },
      error => this.errorMessage = error);
  }
}
like image 554
Dean Avatar asked Jan 16 '17 01:01

Dean


2 Answers

You can write something along these lines:

function getCustomer(id) {
    return Observable.of({'name': 'John', id}).delay(500);
}

Observable.of({'id': 42})
    .distinctUntilChanged(params => params['id'])
    .do(() => {
        // this.errorMessage = '';
    })
    .switchMap((params) => {
        return Observable.combineLatest(
            Observable.of(true).delay(300).startWith(null), // delay Observable
            getCustomer(params['id']).startWith(null), // customer Observable
            function(delay, customer) { // selector function
                if (customer) {
                    return customer;
                }

                if (delay && !customer) {
                    console.log('this.isLoading = true;');
                }
                return null;
            })
            .filter(customer => customer)
            .distinctUntilChanged(customer => customer['id']);
    })
    .subscribe(
        customer => {
            console.log('this.isLoading = false;');
            console.log(customer);
            // this.customer = customer;
        },
        error => {
            // this.errorMessage = error;
        }
    );

See live demo: https://jsbin.com/nebutup/5/edit?js,console

The inner combineLatest() receives two Observables:

  1. The 300ms delay
  2. The customer from a remote service (in this demo simulated)

Then there's also projection function used to select what we want to propagate further. Both Observables use .startWith(null) to make make sure they have at least one item emitted so the combineLatest() will be triggered by a change in any of them. Then we can easily know whether the first Observable that emitted was the delay or the customer.

Then there's also filter() to remove all null values and distinctUntilChanged() to make sure we don't emit the same customer twice (this handles the case where the customer completes first).

Then when we run this demo and the delay is fired first the output is following:

this.isLoading = true;
this.isLoading = false;
{ name: 'John', id: 42 }

This means we first show the loading and then hide it.

Then when we change the getCustomer() to complete first:

function getCustomer(id) {
    return Observable.of({'name': 'John', id}).delay(100);
}

we'll get the following:

this.isLoading = false;
{ name: 'John', id: 42 }

This means we never show any loading.

like image 181
martin Avatar answered Nov 13 '22 02:11

martin


Here's an rxjs 6 piped approach with a reusable operator:

export function delayIndicator<T>(delay: number, start: () => void, complete: () => void): OperatorFunction<T, T> {
  const loadingShown$ = timer(delay).pipe(
    tap(() => start()),
    mapTo(true),
    startWith(false)
  );

  return (input$) =>
    combineLatest([input$, loadingShown$]).pipe(
      take(1),
      map(([input, delayShown]) => {
        if (delayShown) {
          complete();
        }

        return input;
      })
    );
}

myObservable$.pipe(delayIndicator(300, () => this.loading = true, () => this.loading = false));
like image 2
John Avatar answered Nov 13 '22 02:11

John