Let us give reactivity touch to our Notesy App which we have created in our Angular renaissance journey so far. In this article, we will explore some of the capabilities of reactive programming using RxJS/Signals for states and will understand some idealism to follow while dealing with async/state related operations.
Our Notesy App is working like a charm, but it has some areas of improvement from reactive programming POV alongside renaissance of Angular. We will transform the Notesy app into a Reactive app by exploring and using concepts related to RxJS (operators, async operations) and Signals (reactive states). We will also follow some best practices to deal with async operations and reactive states. So, buckle up as will encounter many new concepts if you are considering yourself a beginner to Angular. We will take the same app which we left in the previous article Angular Renaissance: Part 4 - Working with Web API.
We will head in this direction:
In this section we will focus on improving our Note Service code logic which is acting as a state service. In Angular, one of the uses of service is to maintain state of the app, in our case it's the Note Service.
Create Note API Service
Before we jump into the exploration of RxJS concepts, let us first segregate our HTTP based code logic and the shared Note state variable present in the note service. This is logical step to follow the principal SOC (Separation of Concerns). For this we will create a NoteApiService inside new "http" folder ("\src\app\http"). Run the below command to create a new service inside "http" folder:
ng g s note-api
Move the API code logic to the newly created service:
import { inject, Injectable } from '@angular/core';
import { Note } from '../models/note.model';
import { HttpClient } from '@angular/common/http';
import { environment } from '../../environments/environment';
@Injectable({
providedIn: 'root',
})
export class NoteApiService {
http = inject(HttpClient);
apiUrl = environment.apiUrl + 'notes';
getNotes() {
return this.http.get<Note[]>(this.apiUrl);
}
addNote(note: Note) {
return this.http.post<Note>(this.apiUrl, note);
}
deleteNoteById(id: string) {
return this.http.delete<void>(`${this.apiUrl}/${id}`);
}
}
Update the NoteService and remove the HttpClient dependency, instead use the NoteApiService:
import { Injectable, inject } from '@angular/core';
import { Note } from '../models/note.model';
import { NoteApiService } from '../http/note-api.service';
@Injectable({
providedIn: 'root',
})
export class NoteService {
noteApiService = inject(NoteApiService);
notes: Note[] = [];
getNotes() {
return this.noteApiService.getNotes();
}
addNote(note: Note) {
return this.noteApiService.addNote(note);
}
deleteNoteById(id: string) {
return this.noteApiService.deleteNoteById(id);
}
}
Run the app to verify if the changes done so far is working fine or not before moving ahead.
Use RxJS Operators for Subscription Logic
Move the subscription code logic from the components to the Note Service, this will provide us a way to handle the state changes at service level instead of handling it at individual components. Modify the Note Service code logic as below:
import { Injectable, inject } from '@angular/core';
import { Note } from '../models/note.model';
import { NoteApiService } from '../http/note-api.service';
import { LoaderService } from './loader.service';
import { map, finalize } from 'rxjs';
@Injectable({
providedIn: 'root',
})
export class NoteService {
noteApiService = inject(NoteApiService);
loaderService = inject(LoaderService);
notes: Note[] = [];
getNotes(): Observable<Note[]> {
this.loaderService.show();
return this.noteApiService.getNotes().pipe(
map((notes) => (this.notes = notes)),
finalize(() => this.loaderService.hide())
);
}
addNote(note: Note): Observable<Note> {
this.loaderService.show();
return this.noteApiService.addNote(note).pipe(
map((newNote) => (this.noteService.notes = [...this.noteService.notes, newNote])),
finalize(() => this.loaderService.hide())
);
}
deleteNoteById(id: string): Observable<void> {
this.loaderService.show();
return this.noteApiService.deleteNoteById(id).pipe(
map(() => this.notes = this.notes.filter((n) => n.id !== id)),
finalize(() => this.loaderService.hide())
);
}
}
Here, we have moved the entire code logic of next, and complete property of the subscribe method present at component level (list and form components). For this we are using the map and finalize operators.
The map operator transforms the emitted value from the Observable; in our case we are transforming the notes state variable.
The finalize operator triggered in error or non-error cases and we are handling the common operation such as hiding the loader post success or failure case.
These operators are used within the pipe method parameter area. We are manually calling the loader's show method before invoking the actual API calls. Now, modify the list and form component code logic where the Note Service methods been used.
Below shows the list component updated code (we are just showing the snippet of the updates):
@Component({...})
export class ListComponent implements OnInit {
ngOnInit() {
this.noteService.getNotes().subscribe();
}
onRemove(note: Note) {
this.noteService.deleteNoteById(note.id).subscribe();
}
}
Below shows the form component updated code (we are just showing the snippet of the updates):
@Component({...})
export class FormComponent {
onAdd(event: Event) {
this.noteService
.addNote(newNote)
.pipe(
takeUntilDestroyed(this.destroyRef),
finalize(() => (this.content = ''))
)
.subscribe();
}
}
Here, we are using the RxJS finialize operator to rest the content value to empty.
Handle Exception Scenario
In order to handle the exception cases at Note Service level, we need to add these 3 steps to the Note Service methods:
// 1. import catchError
import { catchError } from 'rxjs';
// 2. create handleError method
handleError(error: any) {
alert(`Error Occurred: ${error.message}`);
return of(error);
}
// 3. add catchError operator to the pipe method
catchError(this.handleError),
Let us apply the mentioned changes to the Note Service:
import { map, finalize, of, catchError } from 'rxjs';
@Injectable({...})
export class NoteService {
getNotes(): Observable<Note[]> {
this.loaderService.show();
return this.noteApiService.getNotes().pipe(
map((notes) => (this.notes = notes)),
catchError(this.handleError),
finalize(() => this.loaderService.hide())
);
}
addNote(note: Note): Observable<Note> {
this.loaderService.show();
return this.noteApiService.addNote(note).pipe(
map((newNote) => (this.noteService.notes = [...this.noteService.notes, newNote])),
catchError(this.handleError),
finalize(() => this.loaderService.hide())
);
}
deleteNote(note: Note): Observable<void> {
this.loaderService.show();
return this.noteApiService.deleteNoteById(note.id).pipe(
map(() => this.notes = this.notes.filter((n) => n.id !== note.id)),
catchError(this.handleError),
finalize(() => this.loaderService.hide())
);
}
handleError(error: any) {
alert(`Error Occurred: ${error.message}`);
return of(error);
}
}
Here, we have added the catchError operator post map operator and the handleError method is added at the very end. The catchError get triggered when failure faced by the Observable. Inside the handleError method for now we are just alerting the user regarding the failure, followed by returning the received error back to the call chain.
Here, we are using another opearoter called "of" operator which makes any object to an Observable, we needed this operator because the catchError needs to return and Observable type.
It's time to shift toward Reactive Programming, will make our notes array state variable present in the Note Service reactive. Reactive programming using RxJS and Signals in Angular provide many benefits to maintain and react on state changes. In the beginning the concepts seems extremely tough to digest, but over a period of time one understands the need of it. RxJS used for async operations or streams which provides number of operators, whereas we use Signals to maintain simple state values.
The Angular Signals is most exciting feature, which helps to write reactive states in a convenient way. The Signal concept is a big concept for which a dedicated article is worth to consider, will explore in future.
Currently, the notes array of Note Service is shared and accessed among components, but this state variable can't trigger any event in case if changes occurred, only the value update in the template is respected nothing else. We will see bunch of RxJS operators to handle the async response obtained from the HttpClient along with maintaining the reactive state with notes Signal.
We will first implement the reactivity with the existing state related variables - those are notes array and the loader boolean variable, post that we will see how to react the state changes through effects - for this we will add on some functionality to understand this concept practically.
Create Reactive State
To make the state reactive we need to change its type to Signal with generic type as notes of array. Below shows the necessary changes required for making notes of type Signal:
import { signal } from '@angular/core';
notes = signal<Note[]>([]);
Further, the changes related to notes array will then be reflected as below:
// getNotes
map((notes) => this.notes.set(notes))
// addNote
map((newNote) => this.notes.set([...this.notes(), newNote]))
// deleteNoteById
map(() => this.notes.set(this.notes().filter((n) => n.id !== id)))
Here, we are now using the notes signal for the get, add and delete operations happening in the map operator. To update the state, we need to call the set method and to get current state value we need to use the variable with parathesis as method - notes().
We to do the same for loader state as well, in the loader.service.ts file update the boolean variable to Signal of type boolean:
import { signal } from '@angular/core';
private _loader = signal<boolean>(false);
Use this Signal with show/hide methods:
show() { this._loader.set(true); }
hide() { this._loader.set(false); }
Consume Reactive State
Time to reflect the reactive state notes Signal in the list component:
@for (note of notes() | reverse; track note.id) {
<app-item [note]="note" (onRemove)="onRemove($event)"></app-item>
} @empty {
<app-empty></app-empty>
}
Here, we are using the notes() as method to obtain the state values reactively.
Use the loader Signal in the loader.component.html as - loader():
@if (loader()) {
<h1 class="loader">
<i class="bi bi-snow2 text-success"></i>
</h1>
}
Reactive Input and Output
Angular came up with dope Input and Output Signals, these will be a drop-in replacement for the Input and Output TypeScript based decorators. Let us update our item component with Signal based input and output methods. We will do this in before-after way so that it helps us to understand the narrative shift.
Before:
import { Input, Output, EventEmitter } from '@angular/core';
@Component({...})
export class ItemComponent {
@Input({ required: true }) note!: Note;
@Output() onRemove = new EventEmitter<Note>();
}
After:
import { input, output } from '@angular/core';
@Component({...})
export class ItemComponent {
readonly note = input.required<Note>();
readonly onRemove = output<Note>();
}
Here, we have used the methods from Angular Core module. Since our input note property was previously a mandatory one we will use the input.required method which makes the input note property mandatory. The output declaration became quite easy now, as we don't have the EventEmitter declaration. Lastly, making these properties read-only, as the state of Signals would be updated through the set method.
In the item.component.html, we will have only single line change as shown below:
<!-- before -->
<span>{{note.content}}</span>
<!-- after -->
<span>{{note().content}}</span>
The previous reactive states are using Signals but let us see some more practical usage of Signals so that we can understand how useful they can be and how to achieve true reactive states benefits. For this we will first talk about a requirement in Notesy App to show history of action performed over notes. With this feature user can view the history of changes happened to the notes in the Notesy App. Actions such as Get All, Add and Remove. For keeping things simple, we will keep the history in the localStorage of the browser.
For this we will take below course of actions:
1. Create a History Component
For the UI, we will utilize Bootstrap's Off Canvas component to show case the history of note actions - read more from here Offcanvas. The history.component.html will look like below:
<div class="position-absolute top-0 p-3" style="right: 0">
<button
type="button"
class="btn btn-light"
title="History"
data-bs-toggle="offcanvas"
data-bs-target="#notesHistory"
aria-controls="notesHistory">
<i class="bi bi-clock-history text-success"></i>
</button>
</div>
<div
class="offcanvas offcanvas-end bg-body-tertiary"
tabindex="-1"
id="notesHistory"
aria-labelledby="notesHistoryLabel"
data-bs-scroll="true"
data-bs-backdrop="false">
<div class="offcanvas-header">
<h5 class="offcanvas-title" id="notesHistoryLabel">
<i class="bi bi-clock-history text-success"></i> Notes History
</h5>
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>
</div>
<div class="offcanvas-body">
<div class="mb-3">
<button class="btn btn-link p-0">Clear All History</button>
</div>
<ul class="list-group">
<li class="list-group-item"></li>
</ul>
</div>
</div>
Here, we have added a history button on top right corner of the app - clicking on it will open up an off-canvas panel in the right side to showcase the note action history. Will add this component as a child to app.component.html:
<app-history></app-history>
<app-header></app-header>
<app-navbar></app-navbar>
<div class="container my-5">
<div class="row">
<div class="col">
<router-outlet></router-outlet>
</div>
</div>
</div>
<app-footer></app-footer>
<app-loader></app-loader>
Here, we have added the app-history as first component in the app.component.html:
2. Create a custom Local Storage Service
There are no TypeScript based layer for the default localStorage available in the browser, to just make the localStorage related operations type specific will add a custom Local Storage service to deal with operations - get, set, clear:
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root',
})
export class LocalStorageService {
getItem<T>(key: string): T | null {
try {
const item = localStorage.getItem(key);
if (item === null) {
return null;
}
return JSON.parse(item) as T;
} catch (error) {
console.error(`Error retrieving item from localStorage with key "${key}":`, error);
return null;
}
}
setItem<T>(key: string, value: T): void {
try {
const serializedValue = JSON.stringify(value);
localStorage.setItem(key, serializedValue);
} catch (error) {
console.error(`Error setting item in localStorage with key "${key}":`, error);
}
}
clear(): void {
localStorage.clear();
}
}
Here, we have added getItem, setItem and clear methods to perform type-safe operations with handling basic error scenarios.
3. Create Note Actions state models
Before we create the Note Actions state, lets create the type for understanding type of action and an interface to define the note's action along with a payload to carry the values:
export interface NoteAction {
type: ActionType;
payload?: Note;
}
export type ActionType = 'Add' | 'Remove' | 'GetAll';
Here, we have defined an ActionType with Add, Remove and Get All values - covering over Notesy operations. Also, we have created the note action interface which mention the type and based on type (if its Add/Remove) will supply the payload as well, notice that for Get All operation we wont be supplying any payload as it will hold an array of notes which not actually required, so in that case payload property can be nullable/undefined.
Additionally, will create a constant to hold storage keys name, this will help us to keep the local storage key names at once place for entire app (just following basic coding standards):
export const storageKeys = {
noteActions: 'noteActions',
};
4. Add Note Actions state in the Note Service
Let's add the reactive state for holding up the action information whenever it happens. Adding note actions state (Signal) to Note Service class:
noteAction = signal<NoteAction | null>(null);
We will try the state change on each operation, starting with the get operation:
getNotes() {
return this.noteApiService.getNotes().pipe(
map((notes) => {
this.notes.set(notes);
this.noteAction.set({ type: 'GetAll' });
}),
);
}
Here, with the get operation we are not supplying the payload as its not required for the listeners of the state note action to get what happened with get all, the listener can anyways get that information from notes state directly.
For the add operation we will update the add method:
addNote(note: Note) {
return this.noteApiService.addNote(note).pipe(
map((newNote) => {
this.notes.set([...this.notes(), newNote]);
this.noteAction.set({ type: 'Add', payload: newNote });
}),
);
}
Here, post setting the notes state will set the note action with type as "Add" and payload as new note.
For the remove operation we will update the remove method:
deleteNote(note: Note) {
return this.noteApiService.deleteNoteById(note.id).pipe(
map(() => {
this.notes.set(this.notes().filter((n) => n.id !== note.id));
this.noteAction.set({ type: 'Remove', payload: note });
}),
);
}
Here, notice that we have slightly modified the delete note method to accept the note object instead of note id, this is to keep the payload as Note with the note action state.
Foo
5. Utilize Note Actions state in History Component
Let us take baby steps to consume the note actions state, starting from adding an array of note actions which will happen through time, this array will hold the action information and display in list format.
For this will add the noteActions array in the history.component.ts:
noteActions: NoteAction[] = [];
Consuming this array using @for control flow inside the history.component.html:
<ul class="list-group">
@for (action of noteActions; track $index) {
<li class="list-group-item">
<small>{{ action.type }}</small>
</li>
} @empty {
<small class="text-muted"><i class="bi bi-egg"></i> Nothing to show in the history </small>
}
</ul>
Let's have a color coding defined for each set of action type, for this we will use Bootstrap badges - read more from here Badges. Will add a method in the history.component.ts file:
getTypeClass(action: NoteAction) {
switch (action.type) {
case 'Add':
return 'text-bg-success';
case 'Remove':
return 'text-bg-danger';
default:
return 'text-bg-secondary';
}
}
Here, based on type we are using different Bootstrap classes for the badge. Now let us add a badge span for the same:
<span class="badge me-1" [ngClass]="getTypeClass(action)">{{ action.type | uppercase }}</span>
Here, we are using the pipe upper case to showcase the type value in upper letters. Make sure to add the deps for UpperCasePipe and NgClass in the imports array of the history.component.ts file.
We will also show the content on the Add/Remove operations beside the action type, for the case of GetAll operation will display "No Content":
<small>
<span class="badge me-1" [ngClass]="getTypeClass(action)">{{ action.type | uppercase }}</span>
{{ action.payload?.content || 'No Content' }}
</small>
Finally, the list group will look like below:
<ul class="list-group">
@for (action of noteActions; track $index) {
<li class="list-group-item">
<small>
<span class="badge me-1" [ngClass]="getTypeClass(action)">{{ action.type | uppercase }}</span>
{{ action.payload?.content || 'No Content' }}
</small>
</li>
} @empty {
<small class="text-muted"> <i class="bi bi-egg"></i> Nothing to show in the history </small>
}
</ul>
Add a clear all method and call it on the button click:
// history.component.ts
clearAll() {
this.noteActions = [];
}
// history.component.html
<button class="btn btn-link p-0" (click)="clearAll()">Clear All History</button>
Now, add the note service and listen to the state change of note action using effect method of Signal. The effect method of Signal allows to listen to the state change when ever it occurs, the important point to use effect is that we must use it inside constructor:
noteService = inject(NoteService);
constructor() {
effect(() => {
const action = this.noteAction();
if (action) {
this.noteActions.unshift(action);
}
});
}
get noteAction() {
return this.noteService.noteAction;
}
Here, we have injected note service and added a getter to obtain note action state. Then we have added an effect method inside constructor. The effect method accepts a call back which will be triggered when the state change happened for the Signal been used inside the effect callback. Since we are using noteAction Signal, the effect will be triggered when any change happened to noteAction signal.
Then we will add the notified note action to noteActions array of histrory component which will intern display the action on the history component list.
All these will work and show the note action history while you perform you Notesy operations, but these history data will be vanished if you browser refresh the app or start the app again. We can go ahead further and create a JSON Server endpoint for the same but instead will utilize the local storage to hold up this value.
For this will let's add the noteActions array values to the local storage when ever an update happens in the effect:
effect(() => {
const action = this.noteAction();
if (action) {
this.noteActions.unshift(action);
this.localService.setItem(storageKeys.noteActions, this.noteActions);
}
});
Will do the removal as well on clear button click:
clearAll() {
this.noteActions = [];
this.localService.clear();
}
But what will happen if I browser refresh the app or restart it again - it wont fetch from the local storage. To feed the initial data to noteActions array, we will use ngOnInit life cycle hook to grab the value from local storage:
ngOnInit(): void {
const noteActions = this.localService.getItem<NoteAction[]>(storageKeys.noteActions);
if (noteActions) {
this.noteActions = noteActions;
}
}
Now, these changes will make our Notesy App action history track each and every operations.
Forget your ex but never forget to unsubscribe the subscription in Angular. To unsubscribe we need to call the unsubscribe method on ngOnDestroy lifecycle event of the component. This is hectic task because then you need to create that many subscriptions object, which can be easily produce human errors.
To simplify the unsubscribe activity we can use the takeUntilDestroyed operator along with a DestroyRef and apply on the Note Service method calls at component, thus effectively preventing the memory leak. Let's use the DestroyRef -takeUntilDestroyed way of unsubscribing the subscriptions in the list and form component.
Below the necessary changes applied to list component:
// 1. import DestroyRef and takeUntilDestroyed
import { DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
// 2. inject DestroyRef
private readonly destroyRef = inject(DestroyRef);
// 3. add takeUntilDestroyed to the pipe method of Obsevables
this.noteService.getNotes().pipe(takeUntilDestroyed(this.destroyRef)).subscribe();
this.noteService.deleteNoteById(note.id).pipe(takeUntilDestroyed(this.destroyRef)).subscribe();
Below the necessary changes applied to form component:
// 1. import DestroyRef and takeUntilDestroyed
import { DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
// 2. inject DestroyRef
private readonly destroyRef = inject(DestroyRef);
// 3. add takeUntilDestroyed to the pipe method of Obsevables
this.noteService
.addNote(newNote)
.pipe(
takeUntilDestroyed(this.destroyRef),
finalize(() => (this.content = ''))
)
.subscribe();
We have seen the default state management way in Angular and the RxJS/Signals reactive way as well. Apart from these, there are dedicated libraries for managing state in Angular such as NgRx, Ngxs, Akita, etc. These libraries can be used to efficiently manage the states in Angular. They come with their own ideology and state management approaches, that can be further studied.
The new Angular (v17 onwards) offers a new way of managing and creating such reactive states using Signals, we will touch base regarding the state management in future. For now as a beginner, we can take baby steps to understand state management as it's a very vast topic requires a heavy learning investment and time.
The Notesy App so far is now adapted to reactive way a little bit and the app is also added to the same repository to the folder named "ng-5-reactive".
December 31, 2020
October 19, 2020
March 02, 2022