DEV Community

Cover image for From Imperative to Declarative Angular Development with RxJS
Strahinja Obradovic
Strahinja Obradovic

Posted on

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

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.

Top comments (5)

Collapse
 
strahinja_obradovic profile image
Strahinja Obradovic

Curious about what happens behind the scenes with RxJS Mapping? Check this out!

Collapse
 
monfernape profile image
Usman Khalil

The captain example was top notch and very relevant.🔥

Collapse
 
strahinja_obradovic profile image
Strahinja Obradovic

Thanks Usman! I'm glad you liked it :)

Collapse
 
jangelodev profile image
João Angelo

Hi Strahinja Obradovic,
Thanks for sharing

Collapse
 
strahinja_obradovic profile image
Strahinja Obradovic

Hi Joao,
Happy to!