33
The JUICE of Reactive Programming in Angular
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=1
A 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=brosAY
Have 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!.
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){}
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
})
}
export interface Pagination {
pageIndex: number,
pageSize: number
}
/** <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;
});
/** <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))
}
/** 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);
}
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();
//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$
])
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.**/
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>
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);
}
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))
<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>
@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!.
33