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

2

7

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);
  }
}
Wexford answered 16/1, 2017 at 1:40 Comment(0)
B
8

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.

Bouffard answered 16/1, 2017 at 8:49 Comment(2)
That's much cleaner than my code, thanks. However I've got a delay before setting this.isLoading = true and my question is how to cancel it if this.customerService.getCustomer finishes first - can you help with this?Wexford
@Wexford Ah, I see what you're going for. I updated my answer.Bouffard
G
3

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));
Gingili answered 1/5, 2020 at 14:56 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.