Introduction
This is day 15 of Wes Bos’s JavaScript 30 challenge and I am going to use RxJS and Angular to add, delete and check items in a list, and synchronize data with local storage.
In this blog post, I describe how to use Subject and RxJS operators to listen to form submit and JavaScript events, and synchronize data with the local storage. When events occur, RxJS operators are responsible for updating the user interface and persisting the data to the local storage. If data synchronization is successful, we will be able to restore the UI after closing and reopening browser window.
Create a new Angular project in workspace
ng generate application day15-local-storage
Create List feature module
First, we create a List feature module and import it into AppModule. The feature module ultimately encapsulates two components that are ListContainerComponent and DataListComponent. DataListComponent renders the item list in the local storage whereas ListContainerComponent is the parent of a template form and the DataListComponent.
Then, Import ListModule in AppModule
// list.module.ts
@NgModule({
declarations: [
ListContainerComponent,
DataListComponent
],
imports: [
CommonModule,
FormsModule
],
exports: [
ListContainerComponent
]
})
export class ListModule { }
// app.module.ts
import { APP_BASE_HREF, PlatformLocation } from '@angular/common';
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { AppComponent } from './app.component';
import { ListModule } from './list';
@NgModule({
declarations: [
AppComponent
],
imports: [
BrowserModule,
ListModule
],
providers: [
{
provide: APP_BASE_HREF,
useFactory:(platformLocation: PlatformLocation) => platformLocation.getBaseHrefFromDOM(),
deps: [PlatformLocation]
}
],
bootstrap: [AppComponent]
})
export class AppModule { }
Declare components in feature module
In List feature module, we declare DataListComponent to render data in the local storage. When data row is checked, unchecked or deleted, event emitter emits the row item to ListContainerComponent to synchronize data with the local storage. Moreover, ListContainerComponent has a submit button that appends new item to the list and a button that either checks all items or un-checks all of them. Therefore, the application has multiple sources to synchronize data with the local storage.
The sources are:
- submit form to add data
- a button to check or uncheck all items
- check/uncheck the checkbox of a single row
- delete a single row
It sounds like a lot of work but the RxJS codes in the components are less than 40 lines respectively.
src/app
├── app.component.spec.ts
├── app.component.ts
├── app.module.ts
└── list
├── data-list
│ ├── data-list.component.spec.ts
│ └── data-list.component.ts
├── index.ts
├── interfaces
│ ├── index.ts
│ ├── new-item.interface.ts
│ └── toggle-item.interface.ts
├── list-container
│ ├── list-container.component.spec.ts
│ ├── list-container.component.ts
│ └── type-guard.ts
└── list.module.ts
In DataListComponent, we define app selector, inline template and inline CSS styles. We will add the RxJS codes to implement the logic in the later sections. For your information, is the tag of DataListComponent.
import { ChangeDetectionStrategy, Component, ElementRef, Input, OnDestroy, OnInit, ViewChild } from '@angular/core';
@Component({
selector: 'app-data-list',
template: `
<ul class="plates" #plates>
<li *ngFor="let plate of itemList; index as i">
<input type="checkbox" [attr.data-index]="i" id="item{{i}}" [checked]="plate.done" />
<label for="item{{i}}">{{plate.text}}</label>
<button [attr.data-index]="i" id="btn{{i}}">X</button>
</li>
</ul>
`,
styles: [`
... omitted for brevity ...
`],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class DataListComponent implements OnInit, OnDestroy {
@ViewChild('plates', { static: true, read: ElementRef })
plates!: ElementRef<HTMLUListElement>;
@Input()
itemList!: NewItem[];
ngOnInit(): void {}
ngOnDestroy(): void {}
}
Next, we create ListContainerComponent that encapsulates and a HTML form. For your reference, the tag of ListContainerComponent is .
import { Component, ChangeDetectionStrategy } from '@angular/core';
import { Subject } from 'rxjs';
import { NewItem, ToggleItems } from '../interfaces';
@Component({
selector: 'app-list-container',
template: `
<div class="wrapper">
<h2>LOCAL TAPAS</h2>
<p></p>
<ng-container *ngIf="itemList$ | async as itemList">
<app-data-list [itemList]="itemList"></app-data-list>
</ng-container>
<form class="add-items" (ngSubmit)="submit$.next({ text: newItem, done: false })">
<input type="text" name="item" placeholder="Item Name" [required]="true" name="newItem" [(ngModel)]="newItem">
<input type="submit" value="+ Add Item">
<input type="button" [value]="Check all" (click)="btnCheckAllClicked$.next({ action: 'toggleAll' })">
</form>
</div>
`,
styles: [`
...omitted by brevity...
`],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ListContainerComponent {
newItem = '';
submit$ = new Subject<NewItem>();
toggleDone$ = new Subject<ToggleItem>();
itemList$: Observable<NewItem[]> = of([]);
btnCheckAllClicked$ = new Subject<ToggleItems>();
}
itemList$
is hardcoded of([]) but I will convert it to read from the local storage later.
Next, I delete boilerplate codes in AppComponent and render ListContainerComponent in inline template.
import { APP_BASE_HREF } from '@angular/common';
import { Component, ElementRef, Inject } from '@angular/core';
import { Title } from '@angular/platform-browser';
@Component({
selector: 'app-root',
template: `
<svg xmlns="http://www.w3.org/2000/svg">...svg path...</svg>
<app-list-container></app-list-container>
`,
styles: [`
:host {
min-height: 100vh;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
background-repeat: no-repeat;
background-position: center;
background-size: cover;
}
... omitted for brevity ...
`]
})
export class AppComponent {
title = 'Day15 LocalStorage';
constructor(titleService: Title, private hostElement: ElementRef<HTMLElement>, @Inject(APP_BASE_HREF) private baseHref: string) {
this.hostElement.nativeElement.style.backgroundImage = this.imageUrl;
titleService.setTitle(this.title);
}
get imageUrl() {
const isEndWithSlash = this.baseHref.endsWith('/');
const image = `${this.baseHref}${ isEndWithSlash ? '' : '/' }assets/images/oh-la-la.jpeg`;
return `url('${image}')`;
}
}
Write RxJS code to synchronize data to the local storage – append item
In this section and the following sections, I will incrementally modify itemList$
to update item list in the local storage. First, I listen to the submit event to append the item to the local storage.
In ListContainerComponent, I declare storedItems to get the list items from the local storage keyed items.
storedItems = JSON.parse(localStorage.getItem('items') || JSON.stringify([])) as NewItem[];
Next, I create a NewItem interface that submit$ subject requires to stream data when submit event occurs
// new-item.interface.ts
export interface NewItem {
done: boolean;
text: string;
}
// interfaces/index.ts
export * from './new-item.interface';
In the inline template, ngSubmit emits submit$.next({ text: newItem done: false }) in the form element
<form class="add-items" (ngSubmit)="submit$.next({ text: newItem, done: false })">
After defining the interface and initializing storedItems, I can proceed to modify itemList$
.
// list-container.component.ts
itemList$ = merge(this.submit$)
.pipe(
scan((acc, value) => {
if (isNewItem(value)) {
return acc.concat(value);
}
return acc;
}, this.storedItems),
tap((items) => {
console.log('Update local storage');
localStorage.setItem('items', JSON.stringify(items));
this.newItem = '';
}),
shareReplay(1),
startWith(this.storedItems),
);
itemList$
will listen to other actions; therefore, I use merge
RxJS operator to create a new observable. When value has the shape of NewItem interface, I use scan to append the item to array. Then, I use tap to update the local storage and clear the input field. startWith displays the initial local storage when application starts.
isNewItem
is a type guard; it is a useful TypeScript feature when value is a union type and I need to know the actual type of it to perform the correct action.
export function isNewItem(data: any): data is NewItem {
return 'text' in data;
}
When value has text property, it is a NewItem and I can append it to the list.
Write RxJS code to synchronize data to the local storage – check all and uncheck items
In this section, I click the “Check all” button in ListContainerComponent to check all items and changes the button text to “Uncheck all”. When I click the button again, all items are unchecked as the result and the text reverts to “Check all”.
Next, I define a ToggleItems interface that btnCheckAllClicked$ subject requires to stream data when click event occurs.
// toggle-item.interface.ts
export interface ToggleItems {
action: 'toggleAll'
}
// interfaces/index.ts
export * from './toggle-item.interface';
In the inline template, checkAll button emits btnCheckAllClicked$.next({ action: ‘toggleAll’ }) when click event occurs. Moreover, btnToggleCheckText$ observable monitors the state of itemList$
to update button text accordingly.
<input type="button" [value]="btnToggleCheckText$ | async" (click)="btnCheckAllClicked$.next({ action: 'toggleAll' })">
Let’s modify itemList$
to stream btnCheckAllClicked$ subject.
// list-container.component.ts
itemList$ = merge(this.submit$, this.btnCheckAllClicked$)
.pipe(
scan((acc, value) => {
if (isToggleItems(value)) {
const done = !acc.every(item => item.done);
return acc.map((item) => ({ ...item, done }));
} else if (isNewItem(value)) {
return acc.concat(value);
}
return acc;
}, this.storedItems),
... the rest stays the same ...,
);
isToggleItem
is also a type guard and it tests whether or not value satisfies the shape of ToggleItems interface.
export function isToggleItems(data: any): data is ToggleItems {
return 'action' in data && data.action === 'toggleAll';
}
When value has action property and action is “toggleAll”, then it is a ToggleItems and I toggle the done property of the items.
We are not quite done yet, we still have to update the text of the button.
btnToggleCheckText$ = this.itemList$
.pipe(
map(items => {
const isAllChecked = items.every(item => item.done);
return isAllChecked ? 'Uncheck all' : 'Check all';
}),
startWith('Check all')
);
startWith
initializes the text to ‘Check all’. When items are all done, I map
the text to ‘Uncheck all’, otherwise, it defaults to ‘Check all’.
Emit data from data list to ListContainerComponent to synchronize data in RxJS
Next, I am going to apply RxJS to toggle and delete individual item in DataListComponent and emit the result to ListContainerComponent. I chose event emitter over service to avoid boilerplates. If parent and child components use a lot of subjects and observables for communication, I will centralize them in a shared service instead of scattering event emitters all over the places.
Declare an itemList input that accepts an array of NewItem. itemList receives the data from itemList$
observable in ListContainerComponent.
Declare toggleDone event emitter to emit the result of toggle item and delete item.
@Input()
itemList!: NewItem[];
@Output()
toggleDone = new EventEmitter<ToggleItem>();
destroy$ = new Subject<void>();
ToggleItem is the last interface that we need to record item index and state
// toggle-item.interface.ts
export interface ToggleItem {
action: ItemAction;
index: number;
done: boolean;
}
Implement toggle and delete item in one go
ngOnInit(): void {
fromEvent(this.plates.nativeElement, 'click')
.pipe(
filter(e => (e.target as any).matches('input') || (e.target as any).matches('button')),
map((e: Event) => map((e: Event) => this.createToggleItem(e)),
takeUntil(this.destroy$),
)
.subscribe((value) => {
console.log(value);
this.toggleDone.emit(value);
});
}
private createToggleItem(e: Event): ToggleItem {
const target = e.target as any;
const nodeName = `${target.nodeName}`;
const index = +target.dataset.index;
const done = !this.itemList[index].done;
const action: ItemAction = nodeName === 'INPUT' ? 'toggle' : 'delete';
return { action, index, done };
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
- fromEvent(this.plates.nativeElement, ‘click’) listens to the click event of the unordered list
- filter(e => (e.target as any).matches(‘input’) || (e.target as any).matches(‘button’)) filters the click event of checkbox and button
- map((e: Event) => this.createToggleItem(e)) creates ToggleItem from the event
- takeUntil(this.destroy$) stops the observable until this.destroy$ completes
- In subscribe, I use toggleDone to emit the result of the observable
In createToggleItem
, I use nodeName to derive the action. When node name is INPUT, I toggle the checkbox and action is ‘toggle’. When node name is BUTTON, I click the button to delete the item.
Handle toggle and delete individual item in ListContainerComponent
The inline template of ListContainerComponent receives the output of DataListComponent and processes it in the RxJS code.
// list-container.component.ts
<app-data-list [itemList]="itemList" (toggleDone)="toggleDone$.next($event)"></app-data-list>
toggleDone$ = new Subject<ToggleItem>();
itemList$ = merge(this.submit$, this.toggleDone$, this.btnCheckAllClicked$)
.pipe(
scan((acc, value) => {
if (isToggleItems(value)) {
const done = !acc.every(item => item.done);
return acc.map((item) => ({ ...item, done }));
} else if (isNewItem(value)) {
return acc.concat(value);
}
const { action, done, index } = value
if (action === 'toggle') {
return acc.map((item, i) => i !== index ? item : { ...item, done });
}
return acc.filter((_, i) => i !== index);
}, this.storedItems),
... the rest stays the same ...
);
Finally, I have a simple page that synchronizes data with local storage when create, delete or update occurs.
Close the browser and reopen it and the item list is restored from the local storage.
Final Thoughts
In this post, I show how to use RxJS and Angular to demonstrate component composition and data synchronization with local storage. I am amazed that it did not not a lot of RxJS to handle multiple events. Moreover, RxJS code is declarative that I can comprehend after coming back to the codebase after a couple of days.
This is the end of the blog post and I hope you like the content and continue to follow my learning experience in Angular and other technologies.
Resources:
- Repo: https://github.com/railsstudent/ng-rxjs-30/tree/main/projects/day15-local-storage
- Live demo: https://railsstudent.github.io/ng-rxjs-30/day15-local-storage/
- Wes Bos’s JavaScript 30 Challenge: https://github.com/wesbos/JavaScript30