Google it, Angular State Management, odds are, you will end up on an ngRx solution. Referring to this greate article Choosing the State Management Approach in Angular App, I am here to explore and implement the RxJS based solution.
anchorThe problem
If you are here, you know the problem state management fixes, most probably!
anchorThe solution
One of the approaches to design a solution, is working your way backwards. Given a template, that represents visual components, what do we need to get state organized?
Here is a quick example, say we have a list of records, with basic delete, add and edit functionalities. Most often than not, the functionalities occur in sub routes, or child components. In this part, I want to explore the very basic RxJS state functionality. In future parts (I am hoping), will be adding extra functionalities, and some twist in scenarios. The idea is, stay simple, we do not want to run to NgRX, just yet.
anchorStart here, and work backwards
this.records$ = this.recordService.GetList().pipe(
switchMap(rcs => this.recordState.doSomethingToInitializeState(rcs))
);
The component
<ng-container *ngIf="records$ | async as records">
<ul>
<li *ngFor="let record of records">
<a (click)="editRecord(record)">{{ record.prop }}</a>
<a (click)="delete(record)">Delete</a>
<li>
</ul>
</ng-container>
For simplicity, let us assume that the components that handle creating and editing (the form components) are loaded on the same route, for example, in a dialog. Thus the main list of records does not get reloaded, nor the OnInit
fired again.
this.recordService.SaveRecord({...record}).subscribe({
next: (success) => this.recordState.editOneItemState(record)
});
this.recordService.CreateRecord({...newRecord}).subscribe({
next: (successRecord) => this.recordState.addNewItemToState(successRecord)
});
this.recordService.DeleteRecord({...record}).subscribe({
next: (success) => this.recordState.deleteItemFromState(record);
});
The record service should take care of getting from server or API. So first step is to load the list into state, then to allow editing, deletion and appending of new items. Our state should look like this:
class State {
doSomethingToInitializeState(){ ... }
editOneItemState(item) {...}
addNewItemToState(item) {...}
deleteItemFromState(item) {...}
}
What RxJs provides, is a BehaviorSubject
exposed asObservable
, this subject, is what gets updated (via next
method). Let's name our objects properly from now on. The subject shall be named stateList
, because it represents the list of the elements to be added to the state.
// internal BehaviorSubject initiated with an empty array (safest solution)
private stateList: BehaviorSubject<Record[]> = new BehaviorSubject([]);
// exposed as an observable
stateList$: Observable<Record[]> = this.stateList.asObservable();
// optionally pipe to shareReplay(1)
Let's initiate, add, update, and delete, properly:
SetList(items: Record[]): Observable<Record[]> {
// first time, next items as is
this.stateList.next(items);
// return ready to use observable
return this.stateList$;
}
One of the cool features of BehaviorSubject
is the getValue()
of the current subject, so let me define a getter for the current list:
get currentList(): Record[] {
return this.stateList.getValue();
}
But before we carry on, let us build this class upon a generic, so we can make as many states as we wish later.
export class StateService<T> {
// private now is protected to give access to inheriting state services
protected stateList: BehaviorSubject<T[]> = new BehaviorSubject([]);
stateList$: Observable<T[]> = this.stateList.asObservable().pipe(shareReplay(1));
SetList(items: T[]): Observable<T[]> {
this.stateList.next(items);
return this.stateList$;
}
get currentList(): T[] {
return this.stateList.getValue();
}
// add item, by cloning the current list with the new item
addItem(item: T): void {
this.stateList.next([...this.currentList, item]);
}
// edit item, by finding the item by id, clone the list with the
// updated item (see note below)
editItem(item: T): void {
const currentList = this.currentList;
const index = currentList.findIndex(n => n.id === item.id);
if (index > -1) {
currentList[index] = clone(item); // use a proper cloner
this.stateList.next([...currentList]);
}
}
// find item by id then clone the list without it
removeItem(item: T): void {
this.stateList.next(this.currentList.filter(n => n.id !== item.id));
}
}
To make sure ID exists, we can extend T to a generic interface like this
export interface IState {
id: string;
}
export class StateService<T extends IState> { ... }
As you figured, think state? think immutable. Always clone. In the above, you can use lodash clone function (install the clone function alone), or you can do as I always do, just copy over the code into your source code đŸ˜‚! Happy, in control life. The stackblitz project has that clone ready in core/common.ts
These basic members are good enough for our basic uses, one more thing to cover is allowing the list to grow by appending new items to it (think continuous pagination), thus the need to append new elements to state list.
appendList(items: T[]) {
// update current list
const currentList = this.currentList.concat(items);
this.stateList.next(currentList);
}
We might also need to prepend an item:
prependItem(item: T): void {
this.stateList.next([item, ...this.currentList]);
}
There are other functionalities to include but we will stop here to implement.
anchorExample: list of transactions, add, edit, and delete
Transaction Service
First, the transaction service with the CRUD, assuming the HttpService
is either the HttpClient
or any other provider of your choice, for example Firestore. The stackblitz project works with a local json array in mock-data
folder.
The HttpService is where caching data and handling cache is supposed to go, this part is left out as it is not the scope of state management using RxJS.
import { ITransaction, Transaction } from '../services/transaction.model';
import { HttpService } from '../core/http';
@Injectable({ providedIn: 'root' })
export class TransactionService {
private _listUrl = '/transactions';
private _detailsUrl = '/transactions/:id';
private _createUrl = '/transactions';
private _saveUrl = '/transactions/:id';
private _deleteUrl = '/transactions/:id';
constructor(private _http: HttpService) {}
GetTransactions(options: any = {}): Observable<ITransaction[]> {
// we'll make use of options later
const _url = this._listUrl;
return this._http.get(_url).pipe(
map((response) => {
return Transaction.NewInstances(<any>response);
})
);
}
GetTransaction(id: string): Observable<ITransaction> {
const _url = this._detailsUrl.replace(':id', id);
return this._http.get(_url).pipe(
map((response) => {
return Transaction.NewInstance(response);
})
);
}
CreateTransaction(transaction: ITransaction): Observable<ITransaction> {
const _url = this._createUrl;
const data = Transaction.PrepCreate(transaction);
return this._http.post(_url, data).pipe(
map((response) => {
return Transaction.NewInstance(<any>response);
})
);
}
SaveTransaction(transaction: ITransaction): Observable<ITransaction> {
const _url = this._saveUrl.replace(':id', transaction.id);
const data = Transaction.PrepSave(transaction);
return this._http.put(_url, data).pipe(
map((response) => {
return transaction;
})
);
}
DeleteTransaction(transaction: ITransaction): Observable<boolean> {
const _url = this._deleteUrl.replace(':id', transaction.id);
return this._http.delete(_url).pipe(
map((response) => {
return true;
})
);
}
}
Transaction Model, the basics
import { makeDate } from '../core/common';
export interface ITransaction {
id: string; // important to extend IState interface
date: Date;
amount: number;
category: string;
label: string;
}
export class Transaction implements ITransaction {
id: string;
date: Date;
amount: number;
category: string;
label: string;
public static NewInstance(transaction: any): ITransaction {
return {
id: transaction.id,
date: makeDate(transaction.date),
amount: transaction.amount,
category: transaction.category,
label: transaction.label,
};
}
public static NewInstances(transactions: any[]): ITransaction[] {
return transactions.map(Transaction.NewInstance);
}
// prepare to POST
public static PrepCreate(transaction: ITransaction): any {
return {
date: transaction.date,
label: transaction.label,
category: transaction.category,
amount: transaction.amount,
};
}
// prepare to PUT
public static PrepSave(transaction: ITransaction): any {
return {
date: transaction.date,
label: transaction.label,
category: transaction.category,
amount: transaction.amount,
};
}
}
The Transaction State Service:
@Injectable({ providedIn: 'root' })
export class TransactionState extends StateService<ITransaction> {
// one day, I will have a rich method that does something to state
}
}
Now inside the list component, all we have to do, is get transactions, and load state.
tx$: Observable<ITransaction[]>;
constructor(
private txState: TransactionState,
private txService: TransactionService
) {}
ngOnInit(): void {
this.tx$ = this.txService
.GetTransactions()
.pipe(switchMap((txs) => this.txState.SetList(txs)));
}
In the template, subscribe to your tx$
<ul *ngIf="tx$ | async as txs">
<li *ngFor="let tx of txs;">
<div class="card">
<div class="small light">{{tx.date | date}}</div>
{{tx.label }}
<div class="smaller lighter">{{ tx.category }}</div>
<strong>{{ tx.amount }}</strong>
</div>
</li>
</ul>
anchorUpdating state
To add an element, I am not going in details of the form that creates the new transaction, so we will create a random transaction upon clicking the button, but to make a point, in stackblitz project I will place these buttons in a child component.
append(): void {
// this functionality can be carried out anywhere in the app
this.txService.CreateTransaction(newSample()).subscribe({
next: (newTx) => {
// update state
this.txState.addItem(newTx);
},
error: (er) => {
console.log(er);
},
});
}
prepend(): void {
// prepend to list
this.txService.CreateTransaction(newSample()).subscribe({
next: (newTx) => {
// update state
this.txState.prependItem(newTx);
},
error: (er) => {
console.log(er);
},
});
}
Delete, cute and simple
delete(tx: ITransaction): void {
// this also can be done from a child component
this.txService.DeleteTransaction(tx).subscribe({
next: () => {
this.txState.removeItem(tx);
},
error: (er) => {
console.log(er);
},
});
}
Edit
edit() {
// steer away from bad habits, always clone
const newTx = { ...this.tx, date: new Date() };
this.txService.SaveTransaction(newTx).subscribe({
next: () => {
this.txState.editItem(newTx);
},
error: (er) => {
console.log(er);
},
});
}
This was an example of a root service that gets loaded on a root component, but sometimes, there can be multiple individual instances, or a state of a single object. Coming up, I hope, I will dive a bit deeper with the pagination example.