Reactive programming in angular in the most basic form is the adoption of RxJS (Reactive Extensions for JavaScript) to angular application development. RxJS is a powerful library adopted in Angular that makes asynchronous operations super easy.
This article focuses on revealing to you the juice of reactive programming by providing you a reactive approach to solving one of the most common real world problems encounted by angular developers.
Enough of the long talks, lets get our hands dirty...
Imagine you were assigned a task to create a users table(mat-table) that is populated mainly by making an asynchronous call to an endpoint that returns a list of users. The table should:
Have on it server side pagination.
The parameters provided by the API in this case for pagination include a pageSize and a pageIndex. For example, appending a pageSize of 5 and a pageIndex of 1 to the URL as query string means 5 users will be spooled for the first page.
The URL suffix should look something like this. .../users?pageSize=5&pageIndex=1A search parameter to filter the entire records of users based on specified search input typed in by the user. For this, an input field is to be provided on top of the table to allow users type in their search query. e.g. typing in brosAY should bring in all the users related to brosAY.
The URL suffix should look something like this .../users?pageSize=5&pageIndex=1&searchString=brosAYHave a loader that shows anytime we are making an API call to retrieve new set of users. Mostly when the previous or back button is pressed.
Now lets implement this reactively!.
- The first thing to do is to introduce the angular mat-table How to implement angular mat-table and a formControl on top of the table.
On the template we have
//SEARCH FORM CONTROL
<mat-form-field appearance="fill">
<mat-label>Input your search text</mat-label>
<input matInput placeholder="Search" [formControl]="searchInput">
<button mat-icon-button matPrefix>
<mat-icon>search</mat-icon>
</button>
</mat-form-field>
//USERS TABLE
<div class="mat-elevation-z8">
<table mat-table [dataSource]="dataSource">
<ng-container matColumnDef="id">
<th mat-header-cell *matHeaderCellDef> User ID. </th>
<td mat-cell *matCellDef="let user"> {{element.id}} </td>
</ng-container>
<ng-container matColumnDef="name">
<th mat-header-cell *matHeaderCellDef> Name </th>
<td mat-cell *matCellDef="let user"> {{user.name}} </td>
</ng-container>
<ng-container matColumnDef="age">
<th mat-header-cell *matHeaderCellDef> Age </th>
<td mat-cell *matCellDef="let user"> {{user.age}} </td>
</ng-container>
<ng-container matColumnDef="address">
<th mat-header-cell *matHeaderCellDef> Address </th>
<td mat-cell *matCellDef="let user"> {{user.address}} </td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
</table>
<!-- Mat Paginator -->
<mat-paginator (page)="onPageChange($event)" [length]="dataLength" [pageSizeOptions]="[5, 10, 20, 50, 100]" showFirstLastButtons></mat-paginator>
</div>
In the .ts
displayedColumns: string[] = [
'id',
'name',
'age',
'address',
];
//Form Control for search inputs on the table
searchInput = new FormControl();
//<User> represents the User Model
dataSource = new MatTableDataSource<User>();
//Inject the UserService
constructor(public userService: UserService){}
- Mat paginator by default has a page event that we will be leveraging to handle our pagination. the (page) output event on the paginator emits all that we need to handle our pagination. I will extract mainly the tail end of the HTML code on the template to explain this part.
in the html we have...
<!-- Mat Paginator -->
<mat-paginator (page)="onPageChange($event)" [length]="dataLength" [pageSizeOptions]="[5, 10, 20, 50, 100]" showFirstLastButtons></mat-paginator>
</div>
in the ts we have...
constructor(public userService: UserService){ }
// we initialize the pageIndex to 1 and pageSize to 5
pageIndex: number = 1;
pageSize: number = 5;
//this method receives the PageEvent and updates the pagination Subject.
onPageChange = (event: PageEvent): void => {
// the current page Index is passed to the pageIndex variable
this.pageIndex = event.pageIndex;
// the current page Size is passed to the pageSize variable
this.pageSize = event.pageSize;
/**the pagination method within the user service is called and the
current pagination passed to it**/
this.userService.updatePagination({
pageIndex: this.pageIndex,
pageSize: this.pageSize
})
}
- Because Angular makes use of typescript as its core. we will be creating a model for our pagination. So we create a pagination.models.ts file that will contain our pagination model.
export interface Pagination {
pageIndex: number,
pageSize: number
}
- We proceed to introduce a subject/behaviorSubject that will be constantly updated anytime the pagination requirement changes. - For this scenario, a behaviorSubject is prefered because we need a default state for our pagination which is a pageSize of 5 and a pageIndex of 1. With this in place, the first time the page is accessed, 5 users is always retrieved on the first page by default. This is in contrary to subjects that do not allow a default state. However, applying a startwith rxjs operator on a subject and setting a value can also make it behave just like a behaviorSubject.
/** <Pagination> stands as the BehaviorSubject's model which means that any value that will be assigned to the behaviorSubject must conform to the Pagination model. **/
/** within the () is where we specify the default value for our pagination which is pageSize of 5 and pageIndex of 1 in this case.**/
private paginationSubject = new BehaviorSubject<Pagination>({
pageIndex: 1;
pageSize: 5;
});
- Provision another subject/behaviorSubject that will be constantly updated anytime a search input has been typed in.
/** <string> below as usual, stands for the data type of the value that is allowed to be passed into the subject.
**/
private searchStringSubject = new BehaviorSubject<string>(null);
On the side: To avoid immediate calls to our API when the user starts typing into the form control to initiate a search, we apply a pipe on the valueChanges of the searchInput formControl in order to access the debounceTime (one of RxJS operators) that will help delay passing down the string for API calls until a specified time in ms is provided. e.g debounceTime(500) delays call to the API for .5s before the string is passed down for API call. read more on DebounceTime.
As we have here
//Form Control for search inputs on the table
searchInput = new FormControl();
constructor(public userService: UserService){}
ngOnInit(){
this.trackSearchInput();
}
//method triggers when the search Form Control value changes.
// the changed value doesnt get passed on until after .8s
trackSearchInput = (): void => {
this.searchInput.valueChanges.pipe(debounceTime(800)).subscribe((searchWord: string) => this.userService.updateSearchStringSubject(searchWord))
}
- For best practices, we implement the concept of encapsulation - one of OOP concepts. Notice that a private access modifier was applied on the Behavior Subject meaning that we are restricting the update of the BehaviorSubject only within the service. However to still ensure we get the B-Subject updated from anywhere within our app, we expose a method that can be called anytime an update needs to be done on the BehaviorSubject.
/** this method is the only single point where the pagination subject can be updated. **/
updatePaginationSubject = (pagination: Pagination): void => {
this.paginationSubject.next(pagination);
}
/** Likewise, this method is the only single point where the search string subject can be updated.
**/
updateSearchStringSubject = (searchString: string): void => {
this.searchStringSubject.next(searchString);
}
- Now that we have a method that can be called from any part of our app to set our subjects, we proceed to expose their values by converting them to observables, and also applying a public access modifier on the observables so they be accessed easily from any part of our app. Converting Subjects to observables can be achieved by calling the .asObservable() on them.
For the pagination BehaviorSubject we have:
private paginationSubject = new BehaviorSubject<Pagination>({
pageSize: 5;
pageIndex: 1;
});
//below convert the pagination BehaviorSubject to an observable
public pagination$ = this.paginationSubject.asObservable();
For the search string subject we have:
private searchStringSubject = new BehaviorSubject<string>(null);
searchString$ = this.searchStringSubject.asObservable();
- Now that we have a pagination observable (pagination$) to handle change in paginations and another observable searchString$ to handle change in search input, We move on to combine the two observables using an RxJS operator (combineLatest). We are combining them because we need the latest values from them at every point in time to do our API call to get a new set of users. For combineLatest, all the observables to be combined must have emited atleast once before it emits a value. In cases where you are using a Subject for the search string, you have to adopt the startWith rxjs operator on the search string observable to automatically make the subject behave like a behavior subject.
//Assuming we were using a Subject for Search String we have this
paginatedUsers$ = combineLatest([
this.pagination$,
this.searchString$.pipe(startWith(null)) /**starts with an empty string.**/
])
/**However, because we already have a default state of null for the search string we have this**/
paginatedUsers$ = combineLatest([
this.pagination$,
this.searchString$
])
- Now that we have successfully combined the streams, one more thing needed is an higher order mapping operator like an rxjs switchMap operator that will help handle subscription and unsubscription from inner observables out of the box. In cases where the user initiates an action like clicking the previous button then immediately going on to click the next button, the switchMap RxJS operator IMMEDIATELY helps to cancel the initial request triggered by clicking previous button and IMMEDIATELY moves on to subscribe to the response from the new request triggered on click of the next pagination button. This process is handled graciously by the switchMap operator. Other operators includes a mergeMap which on the other hand would sucbscribe to both calls irrespective of how fast the buttons are clicked.
in the user.service.ts we have:
baseUrl = "https://www.wearecedars.com";
paginatedUsers$: Observable<PagedUsers> = combineLatest([
this.pagination$,
this.searchString$
]).pipe(
/**[pagination - stands for the pagination object updated on page change]
searchString stands for the search input
**/
switchMap(([pagination, searchString]) =>
this.http.get<ApiResponse<PagedUsers>>(`${this.baseUrl}/users?
pageSize=${pagination.pageSize}&pageIndex=${pagination.pageIndex}
${searchString ? '&searchInput=' + searchString : ''}`).pipe(
map(response => response?.Result)
))
).pipe(shareReplay(1))
/**shareReplay(1) is applied in this case because I want the most recent response cached and replayed among all subscribers that subscribes to the paginatedUsers$. (1) within the shareReplay(1) stands for the bufferSize which is the number of instance of the cached data I want replayed across subscribers.**/
- The response e.g. paginatedUsers$ is then subscribed to with the help of an async pipe on the template. async pipe helps you to subscribe and unsubscribe to observables automatically. It basically saves you from the stress of writing long lines of code to handle unsubscriptions.
In our users.component.ts.
constructor(public userService: UserService){}
//the pagedUsers$ below is subscribed to on the template via async pipe
pagedUsers$ = this.userService.paginatedUsers$.pipe(
tap(res=> {
//update the dataSource with the list of allusers
this.dataSource.data = res.allUsers;
/**updates the entire length of the users. search as the upper bound for the pagination.**/
this.dataLength = res.totalElements
})
)
Back to the top.
<ng-container *ngIf="pagedUsers$ | async as pagedUsers">
<mat-form-field appearance="fill">
<mat-label>Input your search text</mat-label>
<input matInput placeholder="Search" [formControl]="searchInput">
<button mat-icon-button matPrefix>
<mat-icon>search</mat-icon>
</button>
</mat-form-field>
<div class="mat-elevation-z8">
<table mat-table [dataSource]="dataSource">
<ng-container matColumnDef="id">
<th mat-header-cell *matHeaderCellDef> User ID. </th>
<td mat-cell *matCellDef="let user"> {{element.id}} </td>
</ng-container>
<ng-container matColumnDef="name">
<th mat-header-cell *matHeaderCellDef> Name </th>
<td mat-cell *matCellDef="let user"> {{user.name}} </td>
</ng-container>
<ng-container matColumnDef="age">
<th mat-header-cell *matHeaderCellDef> Age </th>
<td mat-cell *matCellDef="let user"> {{user.age}} </td>
</ng-container>
<ng-container matColumnDef="address">
<th mat-header-cell *matHeaderCellDef> Address </th>
<td mat-cell *matCellDef="let user"> {{user.address}} </td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
</table>
<!-- Mat Paginator -->
<mat-paginator (page)="onPageChange($event)" [pageSize]="pagedUsers?.pageable?.pageSize"
[pageIndex]="pageIndex"
[length]="dataLength" [pageSizeOptions]="[5, 10, 20, 500, 100]" showFirstLastButtons></mat-paginator>
</div>
</ng-container>
- For the loader, we create a loader component that renders only when the loader observable has a value of true. The above methods are also repeated for the loader.
- Create the Loader Component
- Create the Loader B-Subject in the user service with a default state of false - meaning loader do not show by default
- convert the B-Subject to an observable, expose a method that will be used to update the B-Subject.
subscribe to the loader observable on the template in such a way that the loader shows only when the loader observavle is true.
As soon as the previous, next button is clicked or value is entered for the pagination, the onPageChange method is triggered. before calling the updatePaginationSubject we call the method that sets the loader B-Subject to true. Then as soon as response is returned from the API call to get users, we set the loader subject back to false.
in the user.component.ts
// we initialize the pageIndex to 1 and pageSize to 5
pageIndex: number = 1;
pageSize: number = 5;
onPageChange = (event: PageEvent): void => {
/** set the loader to true; immediately the loader starts showing on
the page **/
this.userService.showLoader();
// the current page Index is passed to the pageIndex variable
this.pageIndex = event.pageIndex;
// the current page Size is passed to the pageSize variable
this.pageSize = event.pageSize;
this.userService.updatePagination({
pageIndex: this.pageIndex,
pageSize: this.pageSize
})
}
in the user Service
/**<boolean> is used as data type because the loading status can either be true or false**/
private loaderSubject = new BehaviorSubject<boolean>(false);
public loading$ = this.loaderSubject.asObservable();
//method sets the loader to true basically
showLoader = (): void => {
this.loaderSubject.next(true);
};
//method sets the loader to false
hideLoader = (): void => {
this.loaderSubject.next(false);
}
- Still in the users service we go on to call the hideLoader method when the API call is successful and we also repeat the same process when it fails. You won't want to have a loader still rolling even after a failed API call.
We have in the user Service
/**<boolean> is used as data type because the loading status can either be true or false**/
private loaderSubject = new BehaviorSubject<boolean>(false);
public loading$ = this.loaderSubject.asObservable();
// method sets the loader to true
showLoader = (): void => {
this.loaderSubject.next(true);
};
// method sets the loader to false;
hideLoader = (): void => {
this.loaderSubject.next(false);
}
paginatedUsers$ = combineLatest([
this.pagination$,
this.searchString$
]).pipe(
switchMap(([pagination, searchString]) =>
this.http.get<ApiResponse<PagedUsers>>(`${this.baseUrl}/users?
pageSize=${pagination.pageSize}&pageIndex=${pagination.pageIndex}&
${searchString ? '&searchInput=' + searchString : ''}`).pipe(
// The actual response result is returned here within the map
map((response) => response?.Result),
/** within the tap operator we hide the Loader. Taps are mostly used for side-effects like hiding loaders while map is used mostly to modify the returned data **/
tap(() => this.hideLoader()),
/** we use the catchError rxjs operator for catching any API errors but for now we will mainly return EMPTY. Mostly, Interceptors are implemented to handle server errors.**/
catchError(err => EMPTY),
/**A finally is implemented to ensure the loader stops no matter. You can have the loader hidden only within the finally operator since the method will always be triggered**/
finally(() => this.hideLoader());
))
).pipe(shareReplay(1))
- On the template we have
<ng-container *ngIf="pagedUsers$ | async as pagedUsers">
<div class="mat-elevation-z8">
<table mat-table [dataSource]="dataSource">
<ng-container matColumnDef="id">
<th mat-header-cell *matHeaderCellDef> User ID. </th>
<td mat-cell *matCellDef="let user"> {{element.id}} </td>
</ng-container>
<ng-container matColumnDef="name">
<th mat-header-cell *matHeaderCellDef> Name </th>
<td mat-cell *matCellDef="let user"> {{user.name}} </td>
</ng-container>
...
</ng-container>
// the loader displays on top of the page when loading...
<app-loader *ngIf="userService.loading$ | async"></app-loader>
- Due to the *ngIf condition specified within the ng-container before the mat-table above, chances are that the table paginations might be showing not work as expected. If something like that happens, You have no reason to worry. The below method will correct that weird behaviour.
@ViewChild(MatPaginator) paginator: MatPaginator;
@ViewChild(MatPaginator) set matPaginator(mp: MatPaginator) {
this.paginator = mp;
}
Finally, our user.component.ts should look like this
displayedColumns: string[] = [
'id',
'name',
'age',
'address',
];
@ViewChild(MatPaginator) paginator: MatPaginator;
@ViewChild(MatPaginator) set matPaginator(mp: MatPaginator) {
this.paginator = mp;
}
pageIndex: number = 1;
pageSize: number = 5;
searchInput = new FormControl();
dataSource = new MatTableDataSource<User>();
pagedUsers$ = this.userService.paginatedUsers$.pipe(
tap(res=> {
this.dataSource.data = res.allUsers;
this.dataLength = res.totalElements
}
))
ngOnInit(){
this.trackSearchInput();
}
trackSearchInput = (): void => {
this.searchInput.valueChanges.pipe(debounceTime(800)).subscribe(
(searchWord: string) => this.userService.updateSearchStringSubject(searchWord))
}
constructor(public userService: UserService) { }
onPageChange = (event: PageEvent): void => {
this.userService.showLoader();
this.pageIndex = event.pageIndex;
this.pageSize = event.pageSize;
this.userService.updatePagination({
pageIndex: this.pageIndex,
pageSize: this.pageSize
})
}
Finally our user template looks like this
<ng-container *ngIf="pagedUsers$ | async as pagedUsers">
<mat-form-field appearance="fill">
<mat-label>Input your search text</mat-label>
<input matInput placeholder="Search" [formControl]="searchInput">
<button mat-icon-button matPrefix>
<mat-icon>search</mat-icon>
</button>
</mat-form-field>
<div class="mat-elevation-z8">
<table mat-table [dataSource]="dataSource">
<ng-container matColumnDef="id">
<th mat-header-cell *matHeaderCellDef> User ID. </th>
<td mat-cell *matCellDef="let user"> {{element.id}} </td>
</ng-container>
<ng-container matColumnDef="name">
<th mat-header-cell *matHeaderCellDef> Name </th>
<td mat-cell *matCellDef="let user"> {{user.name}} </td>
</ng-container>
<ng-container matColumnDef="age">
<th mat-header-cell *matHeaderCellDef> Age </th>
<td mat-cell *matCellDef="let user"> {{user.age}} </td>
</ng-container>
<ng-container matColumnDef="address">
<th mat-header-cell *matHeaderCellDef> Address </th>
<td mat-cell *matCellDef="let user"> {{user.address}} </td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
</table>
<!-- Mat Paginator -->
<mat-paginator (page)="onPageChange($event)" [length]="dataLength" [pageSizeOptions]="[5, 10, 20, 50, 100]" showFirstLastButtons></mat-paginator>
</div>
<ng-container>
<app-loader *ngIf="userService.loading$ | async"></app-loader>
Now to our user.service.ts
//pagination Subject
private paginationSubject = new BehaviorSubject<Pagination>({
pageIndex: 1;
pageSize: 5;
});
//pagination Observable
public pagination$ = this.paginationSubject.asObservable();
//Search string Subject
private searchStringSubject = new BehaviorSubject<string>();
//Search string Observable
public searchString$ = this.searchStringSubject.asObservable();
//Loader subject
private loaderSubject = new BehaviorSubject<boolean>(false);
//Loading observable
public loading$ = this.loaderSubject.asObservable();
/** baseUrl for the users endpoint. In real life cases test URLs should be in the environment.ts while production Urls should be in the environment.prod.ts **/
baseUrl = "https://www.wearecedars.com";
//returns all Paginated Users
paginatedUsers$ = combineLatest([
this.pagination$,
this.searchString$
]).pipe(
switchMap(([pagination, searchString]) =>
this.http.get<ApiResponse<PagedUsers>>(`${this.baseUrl}/users?
pageSize=${pagination.pageSize}&pageIndex=${pagination.pageIndex}&
${searchString ? '&searchInput=' + searchString : ''}`).pipe(
map((response) => response?.Result),
tap(() => this.hideLoader()),
catchError(err => EMPTY),
finally(() => this.hideLoader())
))
).pipe(shareReplay(1))
//Method updates pagination Subject
updatePaginationSubject = (pagination: Pagination): void => {
this.paginationSubject.next(pagination)
}
//Method updates search string Subject
updateSearchStringSubject = (searchString: string): void => {
this.searchStringSubject.next(searchString)
}
//Method sets loader to true
showLoader = (): void => {
this.loaderSubject.next(true);
};
//Method sets loader to false
hideLoader = (): void => {
this.loaderSubject.next(false);
}
In the user.model.ts
export interface Pagination {
pageIndex: number,
pageSize: number
}
export interface APIResponse<T> {
TotalResults: number;
Timestamp: string;
Status: string;
Version: string;
StatusCode: number;
Result: T;
ErrorMessage?: string;
}
export interface PagedUsers {
allUsers: AllUsers[];
totalElements: number;
...
}
export interface AllUsers {
id: number;
name: string;
age: number;
address: string;
}
Congratulations! You have successfully implemented a reactive users table.
In my upcoming article I will be pouring out more of the angular reactive JUICE.
Follow me here and across my social media for more content like this Linkedin
Cheers!.
Top comments (0)