From Imperative to Declarative Angular Development with RxJS

strahinja_obradovic

Strahinja Obradovic

Posted on May 16, 2024

From Imperative to Declarative Angular Development with RxJS

Photo by Philippe Oursel on Unsplash

This article exemplifies declarative data access in Angular using the RxJS flattening operator.

Background Story

Picture yourself as the captain of a ship. Your crew frequently seeks guidance. As new tasks emerge, repeating instructions leads to chaos.

  • As an Imperative Captain, you may notice these shortcomings but still choose to continue repeating how to do something.

  • As a Declarative Captain, you recognize the need for change, as your daily life on board will become exhausting. You take the initiative to gather the crew and decisively define what needs to be done once and for all.

Back to Angular

The Angular component is responsible for displaying a list of auctions based on the query state.

Query Type

AuctionQuery

export interface AuctionQuery extends PaginationQuery {
    itemTitle: string | null,
}
Enter fullscreen mode Exit fullscreen mode
export interface PaginationQuery {
    page: number,
    itemsPerPage: number
}
Enter fullscreen mode Exit fullscreen mode

Response Type

PaginationResponse<AuctionModel>

export interface AuctionModel {
    id: number
    itemTitle: string
    start: Date
    startingBid: number
}
Enter fullscreen mode Exit fullscreen mode
export type PaginationResponse<T> = {
    count: number,
    rows: T[]
}
Enter fullscreen mode Exit fullscreen mode

Component Template

The template includes:

  • Search component

  • List of auctions matching the query

  • Pagination component

Imperative

This is how Imperative Captain does things.

Take note of the auctions property, as it serves as the component's data.

TS:

export class AuctionListComponent implements OnInit, OnDestroy {

  auctions: PaginationResponse<AuctionModel> | null = null;
  itemsPerPage = this.auctionService.query.itemsPerPage;
  subscriptions = new Subscription();

  constructor(private auctionService: AuctionService) { }

  ngOnInit(): void {
    this.setAuctions();
  }

  setAuctions() {
    this.subscriptions.add(
      this.auctionService.getAuctions().pipe(
        tap({
          next: (v: PaginationResponse<AuctionModel>) => {
            this.auctions = v;
          }
        })
      ).subscribe()
    );
  }

  queryPage(page: number) {
    this.auctionService.query.page = page;
    this.setAuctions();
  }

  querySearch(term: string) {
    this.auctionService.query.page = 1;
    this.auctionService.query.itemTitle = term;
    this.setAuctions();
  }

  ngOnDestroy(): void {
    this.subscriptions.unsubscribe();
  }
Enter fullscreen mode Exit fullscreen mode

Service:

export class AuctionService {

  query: AuctionQuery = {
    itemTitle: null,
    page: 1,
    itemsPerPage: 3
  }

  ...

  getAuctions(): Observable<PaginationResponse<AuctionModel>> {
    const options = { params: this.createHttpParams(this.query) };
    return this.http.get<PaginationResponse<AuctionModel>>(this.apiUrl, options).pipe(
      catchError((err: HttpErrorResponse) => {
        throw new Error('could not load data');
      })
    )
  }
}
Enter fullscreen mode Exit fullscreen mode

Template:

<app-search (termChange)="querySearch($event)"></app-search>
<div class="auctions-container">
    @if (auctions) {
        @for (auction of auctions.rows; track $index) {
            <app-auction [auction]="auction"></app-auction>
        }
    }
</div>
@if (auctions) {
    <app-pagination 
        [recordsPerPage]="itemsPerPage"
        [totalRecords]="auctions.count" 
        (pageSelected)="queryPage($event)">
    </app-pagination>
}
Enter fullscreen mode Exit fullscreen mode

Flaws

  • Reassigning property at different places in the code leads to poor readability. It's not clear how property changes over time.
  • Manual handling of subscriptions.

Declarative

This is how Declarative Captain does things.

We need to clearly define the data source during component initialization. This definition should also include the query state, marking the shift from an imperative (how) to a declarative (what) approach.

First, we need observable emitting query updates:

export class AuctionQueryObservable {

    query: AuctionQuery = {
        itemTitle: null,
        page: 1,
        itemsPerPage: 3
    }
    querySubject = new BehaviorSubject<AuctionQuery>(this.query);

    titleUpdate(title: string){
        this.query.page = 1;
        this.query.itemTitle = title;
        this.querySubject.next(this.query);
    }

    pageUpdate(page: number){
        this.query.page = page;
        this.querySubject.next(this.query);
    }
}
Enter fullscreen mode Exit fullscreen mode

Now let's see how we can use observable query:

export class AuctionService {

  queryWithSubject = new AuctionQueryObservable();
  ...

  getAuctions(): Observable<PaginationResponse<AuctionModel>> {
    return this.queryWithSubject.querySubject.pipe(
      switchMap(updatedQuery => {
        const options = { params: this.createHttpParams(updatedQuery) };
        return this.http.get<PaginationResponse<AuctionModel>>(this.apiUrl, options).pipe(
          catchError((err: HttpErrorResponse) => {
            throw new Error('could not load data');
          })
        )
      })
    ) 
  }
}
Enter fullscreen mode Exit fullscreen mode

We are utilizing the switchMap (RxJS flattening operator):
On each query change, the previous inner observable (the result of the passed function) is canceled, and the new observable is subscribed.

TS:

export class AuctionListComponent {

  auctions$ = this.auctionService.getAuctions();
  itemsPerPage = this.auctionService.queryWithSubject.query.itemsPerPage;

  constructor(private auctionService: AuctionService) { }

  querySearch(term: string){
    this.auctionService.queryWithSubject.titleUpdate(term);
  }

  queryPage(page: number){
    this.auctionService.queryWithSubject.pageUpdate(page);
  }

}
Enter fullscreen mode Exit fullscreen mode

The way we defined auctions allows us to utilize async pipe for handling subscriptions.

Template:

<app-search (termChange)="querySearch($event)"></app-search>
@if(auctions$ | async; as auctions){
    @if(auctions.count > 0){
        <div class="auctions-container">
            @for(auction of auctions.rows; track auction.id) {
                <app-auction [auction]="auction"></app-auction>
            }
        </div>
        <app-pagination 
            [recordsPerPage]="itemsPerPage"
            [totalRecords]="auctions.count" 
            (pageSelected)="queryPage($event)">
        </app-pagination>
    } @else {
        <h2>nothing found</h2>
    }
}
Enter fullscreen mode Exit fullscreen mode

Additionally

We can use signals (from version 16 or 17) to monitor the query changes. The aim is to encapsulate a value and notify the consumer of any alterations. This solution, while similar to the previous, is more conventional and unlocks new possibilities.

Instead of Behaviour Subject:

export class AuctionQueryService {
    query = signal<AuctionQuery>({
        itemTitle: null,
        page: 1,
        itemsPerPage: 3
    })

    titleUpdate(itemTitle: string){
        this.query.update((query)=>{
            return {
                ...query,
                page:1,
                itemTitle,
            }
        })
    }

    pageUpdate(page: number){
        this.query.update((query)=>{
            return {
                ...query,
                page
            }
        })
    }
}
Enter fullscreen mode Exit fullscreen mode
export class AuctionService {
  ...

  getAuctions(query: AuctionQuery): Observable<PaginationResponse<AuctionModel>> {
        const options = { params: this.createHttpParams(query) };
        return this.http.get<PaginationResponse<AuctionModel>>(this.apiUrl, options).pipe(
          catchError((err: HttpErrorResponse) => {
            throw new Error('could not load data');
          })
        )
  }

}
Enter fullscreen mode Exit fullscreen mode

Updated List Component:

export class AuctionListComponent {

  auctions = toSignal(
    toObservable(this.auctionQueryService.query).pipe(
      switchMap((query: AuctionQuery) => {
        return this.auctionService.getAuctions(query);
      })
  ));
  itemsPerPage = computed(()=>{
    return this.auctionQueryService.query().itemsPerPage;
  })
  totalRecords = computed(()=>{
    return this.auctions()?.count || 0;
  })

  constructor(private auctionService: AuctionService, private auctionQueryService: AuctionQueryService) { }

  querySearch(term: string){
    this.auctionQueryService.titleUpdate(term);
  }

  queryPage(page: number){
    this.auctionQueryService.pageUpdate(page);
  }

}
Enter fullscreen mode Exit fullscreen mode

The signal is converted to an observable using the toObservable function so we can continue the pipe and use the switchMap operator.

Signals have the advantage of being created from existing ones using the computed function. That's why auctions property is eventually converted to a signal using the toSignal function (and also for consistency).

Finally, changes are needed on the template because we are using signals instead of observables:

<app-search (termChange)="querySearch($event)"></app-search>
<div class="auctions-container">
    @if(totalRecords() > 0){
        <div class="auctions-container">
            @for(auction of auctions()?.rows; track auction.id) {
                <app-auction [auction]="auction"></app-auction>
            }
        </div>
        <app-pagination 
            [recordsPerPage]="itemsPerPage()"
            [totalRecords]="totalRecords()" 
            (pageSelected)="queryPage($event)">
        </app-pagination>
    } @else {
        <h2>nothing found</h2>
    }
</div>
Enter fullscreen mode Exit fullscreen mode

Conclusion

It's not necessary to be entirely declarative at all times. Declarative code is essentially imperative under the hood, but it's often more efficient to specify what we want and let the framework handle it. By telling what, the code can be cleaner and more extendable.

I hope this will be helpful to someone. I'm here to learn as well, so any suggestions are welcome.

💖 💪 🙅 🚩
strahinja_obradovic
Strahinja Obradovic

Posted on May 16, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related