3

I'm starting with reactive programming and I have the following application: Test app

The three dotted items is a <ul> tag. Each time a li item is clicked, the app navigates to a route using the routerLink appending a query string (/bands, /bands?active=false and /bands?active=true).

The part of the web page starting at Bands (belong to the component called band-list) is the content the router-outlet displays according to the route. The grid below is loaded according to the query param 'active'. Then, each time a li item is clicked, the grid reloads.

Also, the Refresh data button reloads the grid.

I'm trying to implement, using reactive programming, the following logic:

  • At initialization, the band-list component should load the grid according to the active query parameter in the route.

  • Each time the user clicks a li, the grid should reload. If previous to this click on a li the grid is still loading (making an http request), the http request in progress should be cancelled (this looks to me like a switchMap operator).

  • If the user clicks the refresh data button and the grid has finished loading its data, the grid should load. However, if the grid is still loading its data (in the middle of an http request), the click should do nothing, i.e., the in-process loading data operation should finished, and the click in the button is practically ignored (this looks like an exhaustMap to me :S)

  • Also, each time an http operation is performed, a message telling "Loading..." should show up while the http operation has no finished, and the data in the grid should be emptied. When the http request finishes, the "Loading..." message should be removed and the data should show up in the grid.

I implemented the code in the band-list component like this next code block, but I'm not sure about the use of the defer operator for my solution. I used the defer operator since I need to maintain the state if a current request is in process with the isLoading variable inside the defer operator, and also updating this variable as a side-effect action in the tap operator, and using this variable as a side-effect in the filter operator.

My question is: is this a nice way to do it according to reactive programming? What could I do better?

You can check this solution in this stackblitz' url

@Component({
    selector: 'app-band-list',
    templateUrl: 'band-list.component.html',
    changeDetection: ChangeDetectionStrategy.OnPush
})
export class BandListComponent {
    readonly initialState: BandState = { data: [], isLoading: false };
    #bandDataService = inject(BandDataService);

    refreshDataClickSubject = new Subject<void>();
    #activedRoute = inject(ActivatedRoute);

    bandState$;

    constructor() {
        this.bandState$ = this.getBands$();

        const destroyRef = inject(DestroyRef);
        destroyRef.onDestroy(() => {
            this.refreshDataClickSubject.complete();
            this.refreshDataClickSubject.unsubscribe();
        });
    }

    getBands$(): Observable<BandState> {
        const queryParams$ = this.#activedRoute.queryParams;
        const refreshDataClick$ = this.refreshDataClickSubject.asObservable();

        const isActiveFromQueryParams$ = queryParams$.pipe(
            map<any, boolean | undefined>(p => p["active"])
        );

        const final$ = defer(() => {
            let isLoading = false;

            const getBandsFn$ = (isActive: boolean | undefined) => concat(
                of([]).pipe(
                    tap(_ => isLoading = true), // <== here updating the outer state. 
                    map(data => ({data, isLoading: true}))
                ),
                this.#bandDataService.getBands$(isActive).pipe(
                    map(data => ({data, isLoading: false}))
                )
            ).pipe(
                finalize(() => isLoading = false) // <== again updating the outer state.
            );

            const obs$ = merge(
                queryParams$.pipe(map(_ => RequestOrigin.QueryParams)),
                refreshDataClick$.pipe(map(_ => RequestOrigin.RefreshButton))
            ).pipe(
                filter(requestOrigin => requestOrigin === RequestOrigin.QueryParams ||
                    (requestOrigin === RequestOrigin.RefreshButton && !isLoading)),
                withLatestFrom(isActiveFromQueryParams$),
                map(([_, isActive]) => isActive),
                switchMap(isActive => getBandsFn$(isActive)),
                startWith(this.initialState)
            );
            return obs$;
        });

        return final$;
    }
}

enum RequestOrigin {
    QueryParams, RefreshButton
}

interface BandState {
    data: Band[],
    isLoading: boolean
}
<h2>Bands</h2>
<div class="row">
    <div class="col-sm-12">
        <div class="form-group">
            <button class="btn btn-primary" type="button" (click)="refreshDataClickSubject.next()">
                Refresh data
            </button>
        </div>

        @if(bandState$ | async; as bandState) {
            @if (bandState.isLoading) {
                Loading...
            } @else {
                <table class="table">
                    <thead>
                        <tr>
                            <th>Name</th>
                            <th>Formation year</th>
                            <th>Is active</th>
                        </tr>
                    </thead>
                    <tbody>
                        @for(band of bandState.data; track $index) {
                            <tr>
                                <td>{{ band.name }}</td>
                                <td>{{ band.formationYear }}</td>
                                <td>{{ band.isActive }}</td>
                            </tr>
                        } @empty {
                            <h3>Empty!</h3>
                        }
                    </tbody>
                </table>
            }
        } @else {
            BandState$ not initialized
        }
    </div>
</div>
4
  • 1
    Your current StackBlitz throws some errors, since some code is missing. E.g. bands$observable missing in band-data.service, which your are trying to access in line 74 of band-list.component. Same with isActiveChanged. Could you please update your code.
    – JB17
    Commented Jan 20, 2024 at 12:38
  • I am also getting error while running StackBlitz. Can you please check and update with working demo. Commented Jan 20, 2024 at 18:10
  • @JB17 sorry, the code was fix Commented Jan 21, 2024 at 22:18
  • @RohìtJíndal sorry, the code was fix Commented Jan 21, 2024 at 22:18

3 Answers 3

6
+25

There is a much simpler way to achieve your desired behavior. But first, let's start with a very basic implementation that gets you 90% there:

private isActive$ = inject(ActivatedRoute).queryParamMap.pipe(
  map(params => params.get('active'))
);

bandState$: Observable<BandState> = this.isActive$.pipe(
  switchMap(isActive => this.#bandDataService.getBands$(isActive).pipe(
    map(data => ({ data, areBandsLoading: false })),
    startWith({ data: [], areBandsLoading: true })
  ))
);

isActive$ is an observable that emits the value of the query param. When it emits the value, we switchMap to the call to get the data. We then map the result from the api call to your BandState shape. Lastly, we specify an initial value to emit using startWith just like you've already done in your code.

This means that whenever the query param changes, a call will be made to get the appropriate data and the bandState$ observable will emit the updated state.

The reason I show this simple implementation first is because it is not too far from the solution that gets you the "refresh" behavior that you want.


In order to incorporate your "refresh" functionality, we can use a BehaviorSubject as a notification trigger.

refresh$ = new BehaviorSubject<void>(undefined);

Then, instead of piping the isActive$ emission directly to the http call, we will send it to this intermediate trigger, then use exhaustMap to handle the call. The reason we use BehaviorSubject instead of Subject is so we receive an emission upon subscription, meaning our switchMap doesn't need to wait for refresh$ to be triggered the first time, it happens automatically:

bandState$: Observable<BandState> = this.isActive$.pipe(
  switchMap(isActive => this.refresh$.pipe(
    exhaustMap(() => this.#bandDataService.getBands(isActive).pipe(
      map(data => ({ data, areBandsLoading: false })),
      startWith({ data: [], areBandsLoading: true })
    ))
  ))
);

Check out this working StackBlitz

4
  • isLoading gonna stay true in case of error Commented Jan 23, 2024 at 8:40
  • true, this is just a simple implementation, but handling the error could easily be added.
    – BizzyBob
    Commented Jan 23, 2024 at 23:48
  • This is the beauty of the reactive paradigm, conciseness Commented Jan 25, 2024 at 15:04
  • That was the answer I was looking for! First, I wasn't sure if I should have designed the template with one or 2 observables: one observable emitting the band data and the loading message; or 2 observables, one emitting the band data and another one for the message data. I should say, also, your pipeline is really clever. I didn't think of that solution. Certainly, I must train more in reactive programming. Commented Jan 27, 2024 at 19:38
0

We can take two streams, apply the switchMap and exhaustMap and use merge the results, we can use a merge with of(null) so we can trigger the API on load itself!

fakeService

import { Injectable, inject } from '@angular/core';
import { delay, map } from 'rxjs/operators';
import { Band } from './model';
import { Observable, of } from 'rxjs';
import { HttpClient, HttpParams } from '@angular/co mmon/http';

@Injectable({
  providedIn: 'root',
})
export class BandDataService {
  http = inject(HttpClient);

  getBands$(active?: boolean): Observable<Band[]> {
    console.log(active);
    const params = new HttpParams().set(
      'active',
      active === undefined ? '' : active
    );
    return this.http.get<Band[]>('https://dummyjson.com/products/1').pipe(
      map(() => ([
        {
          id: '1',
          bio: 'asdf',
          name: 'test',
          formationYear: 2022,
          isActive: true,
        },
        {
          id: '1',
          bio: 'asdf',
          name: 'test',
          formationYear: 2022,
          isActive: false,
        },
      ])),
      delay(3000),
    );

    // return of([]).pipe(delay(1_000));
  }

  updateIsActive(id: string, isActive: boolean) {
    return of([]);
    // console.log(`Setting isActive to ${isActive} for band with id ${id}`);
    // const band = this.bands.find(band => band.id === id)!;
    // band.isActive = isActive;
    // return of({ ...band }).pipe(
    //  delay(1000)
    // );
  }
}

ts

import {
  ChangeDetectionStrategy,
  Component,
  DestroyRef,
  inject,
} from '@angular/core';
import { BandDataService } from '../band-data.service';
import {
  BehaviorSubject,
  Observable,
  Subject,
  combineLatest,
  concat,
  defer,
  filter,
  finalize,
  iif,
  map,
  merge,
  of,
  startWith,
  switchMap,
  tap,
  withLatestFrom,
} from 'rxjs';
import { Band } from '../model';
import { ActivatedRoute, Params } from '@angular/router';
import { concatMap, exhaustMap, mergeMap } from 'rxjs/operators';

@Component({
  selector: 'app-band-list',
  templateUrl: 'band-list.component.html',
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class BandListComponent {
  bandDataService = inject(BandDataService);

  refreshDataClickSubject = new Subject<string>();
  activedRoute: ActivatedRoute = inject(ActivatedRoute);

  bandState$;
  isActive!: boolean;

  constructor() {
    this.bandState$ = this.getBands$();
  }

  getBands$(): Observable<Band[] | null> {
    const queryParams$ = this.activedRoute.queryParams;
    const refreshDataClick$ = this.refreshDataClickSubject.asObservable();

    const isActiveFromQueryParams$: Observable<boolean> = queryParams$.pipe(
      map((p: Params) => {
          const active = p['active'] === 'true';
          this.isActive = active;
          return active;
      }),
    );

    const getBandsFn$ = () =>
      merge(of(null), this.bandDataService.getBands$(this.isActive));

    const final$ = defer(() => {
      const obs$: Observable<Band[] | null> = merge(
        isActiveFromQueryParams$.pipe(
          switchMap(() => {
            return getBandsFn$();
          })
        ),
        refreshDataClick$.pipe(
          exhaustMap(() => {
            return getBandsFn$();
          })
        ),
      );
      return obs$;
    });

    return final$;
  }
}

html

<h2>Bands</h2>
<div class="row">
  <div class="col-sm-12">
    <div class="form-group">
      <button
        class="btn btn-primary"
        type="button"
        (click)="refreshDataClickSubject.next('refresh')"
      >
        Refresh data
      </button>
    </div>

    @if(bandState$ | async; as bandState) {
    <table class="table">
      <thead>
        <tr>
          <th>Name</th>
          <th>Formation year</th>
          <th>Is active</th>
        </tr>
      </thead>
      <tbody>
        @for(band of bandState; track $index) {
        <tr>
          <td>{{ band.name }}</td>
          <td>{{ band.formationYear }}</td>
          <td>{{ band.isActive }}</td>
        </tr>
        } @empty {
        <h3>Empty!</h3>
        }
      </tbody>
    </table>
    } @else { Loading... }
  </div>
</div>

stackblitz

4
  • The idea is to use network requests to see if they are cancelled when a new request happens. In your code there is no request made and, as far as I can observe, all the previous actions for reloading are discarded due to the switchMap, but that is not the case that I need. Please, read the logic explained in my question. Commented Jan 16, 2024 at 15:06
  • yes, the latest request is the one it is needed, but read again the logic required. When there's an ongoing request that hasn't finished and the user clicks the refresh button, the action fired by the button is ignored Commented Jan 16, 2024 at 15:53
  • the problem with exhaustMap is that you don't comply with the other rule: Each time the user clicks a li, the grid should reload. If before this click on a li the grid is still loading (making an http request), the http request in progress should be cancelled (this looks to me like a switchMap operator) and a new request should be fired.. So, the answer doesn't comes down to only use one higher order observable, you have to balance up mergeMap and exhaustMap. Commented Jan 16, 2024 at 20:08
  • For you to be able to see how the http request are cancelled, it is better to try out a solution using the server that is along with the angular project. Read the README file Commented Jan 16, 2024 at 20:11
0

A different approach with Signals:

Starting by declaring bands and areBandsLoading signals:

protected readonly bands = signal<Band[]>([]);
protected readonly areBandsLoading = signal<boolean>(false);

Then we define our fetchBands method which returns an Observable (we don't subscribe here because it will be used in other observable route.queryParamMap to avoid nested subscribtions) and we made it private because it is only used inside the class:

private fetchBands(active: string | null): Observable<Band[]> {
  this.areBandsLoading.set(true);
  return this.bandsService.getBands(active).pipe(
    finalize(() => this.areBandsLoading.set(false)),
    first(),
    tap((data) => this.bands.set(data))
  );
}

We used finalize to set loading state to false in case error happened, first to not keep the observale alive after fetching is done, and tap to set the value of bands signal.

Then we subscribe to queryParamMap to fetch the data when the query params change:

this.route.queryParamMap
  .pipe(
    takeUntilDestroyed(),
    switchMap((map) => this.fetchBands(map.get('active')))
  )
  .subscribe();

We used switchMap to cancel the old stream completely when a new value emits, this is an optimization in case a new fetch request was made while the previous one is not done yet, it will cancel the old request (you can check it in network tab if you click on different li quickly in this example) and takeUntilDestroyed to make sure the stream is completed when the component is destroyed (prevent memory leaks).

Small note, takeUntilDestroyed is still in developer preview

Finally refreshData method is protected as it is used in the template outside the class and it simply read the value of active query param from the route snapshot and fetch the data again:

protected refreshData(): void {
  const active = this.route.snapshot.queryParamMap.get('active');
  this.fetchBands(active).subscribe();
}

Check out the full code on StackBlitz

4
  • Please share code and explain what it achieves, not just a link
    – maxime1992
    Commented Jan 23, 2024 at 17:08
  • While this link may answer the question, it is better to include the essential parts of the answer here and provide the link for reference. Link-only answers can become invalid if the linked page changes. - From Review
    – wohlstad
    Commented Jan 23, 2024 at 18:05
  • My bad, thanks for pointing this out guys, will update my answer.
    – Ali Ataf
    Commented Jan 23, 2024 at 18:11
  • Really clever and interesting solution with signals, although I think you should handle the unsubscription too, right? Commented Jan 27, 2024 at 19:49

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.