From Imperative to Declarative Angular Development with RxJS
Strahinja Obradovic
Posted on May 16, 2024
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,
}
export interface PaginationQuery {
page: number,
itemsPerPage: number
}
Response Type
PaginationResponse<AuctionModel>
export interface AuctionModel {
id: number
itemTitle: string
start: Date
startingBid: number
}
export type PaginationResponse<T> = {
count: number,
rows: T[]
}
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();
}
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');
})
)
}
}
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>
}
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);
}
}
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');
})
)
})
)
}
}
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);
}
}
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>
}
}
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
}
})
}
}
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');
})
)
}
}
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);
}
}
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>
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.
Posted on May 16, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.