I was placed on an Angular project that did not have a state management system like Redux or ngrx. I saw this as an opportunity to gently introduce state management using RxJS.
There are n+1
blog posts about Reactive Programming. In a nutshell, reactive programming concerns itself with async data. This data can come from APIs, or from user events.
The task was to build a toast notification system. Something similar to Angular Material’s Snackbar. The requirements were:
- each toast notification auto-expires
- each toast notification can be closed ahead of time by a user
- and there can be many toast notifications at once.
I ended up using the scan
operator from RxJS to have data persistence. You can think of a scan operator as JavaScript's reduce
method.
This was accomplished inside an Angular service, however, you can take these concepts in any project that uses RxJS.
// toast.service.ts
// getter so no bad developer uses the store incorrectly.
get store() { return this._store$; }
// Dispatch "actions" with a subject
private _action$ = new Subject();
// Create a store which is just an array of 'Toast'
private _store$: Observable<Toast[]> = this._action$.pipe(
map((d: ToastAction) => (!d.payload.id) ? this.addId(d) : d), // add id to toast to keep track of them.
mergeMap((d: ToastAction) => (d.type !== ToastActionType.Remove) ? this.addAutoExpire(d) : of(d)), // concat a hide toast request with delay for auto expiring
scan(this.reducer, []) // magic is here!
);
// dispatch method
public dispatch(action: ToastAction): void {
this._action$.next(action);
}
// generate ids for the toast
private addId(d: ToastAction): ToastAction {
return ({
type: d.type,
payload: { ...d.payload, id: this.generateId() }
});
}
// If a user does not click on the toast to clear it, then it should auto expire
private addAutoExpire(d: ToastAction) {
const signal$ = of(d);
const hide$ = of({ type: ToastActionType.Remove, payload: d.payload }).pipe(delay(this.config.duration));
return concat(signal$, hide$);
}
// generates a random string
private generateId(): string {
return '_' + Math.random().toString(36).substr(2, 9);
}
// The reducer which adds and removes toast messages.
private reducer(state: Toast[] = [], action: ToastAction): Toast[] {
switch (action.type) {
case ToastActionType.Add: {
return [action.payload, ...state];
}
case ToastActionType.Remove: {
return state.filter((toast: Toast) => toast.id !== action.payload.id);
}
default: {
return state;
}
}
}
}
Being able to use scan was perfect for this situation. I could reduce incoming streams of data into an array of objects using the reducer function.
To use the store, you can either subscribe to the store
or reference it in your Angular template.
// toast.component.ts
export class ToastComponent {
public state$: Observable<Toast[]> = this.toastService.store;
constructor(public toastService: ToastService) { }
public add() {
this.toastService.dispatch({ type: ToastActionType.Add, payload: { message: 'hi', status: ToastStatus.Info } });
}
public remove(payload: Toast) {
this.toastService.dispatch({
type: ToastActionType.Remove,
payload
});
}
...
But is this ideal? Yes it works, however, a developer that comes by shouldn't have to care about ToastActionType
and construction objects. It's a lot of work. Let's create some helper methods in our ToastService so any developer can call 'enqueueSuccess' or 'enqueueInfo' for the type of toast we want.
// toast.service.ts
@Injectable({
providedIn: 'root'
})
export class ToastService {
get store() { return this.store$; }
private action$: Subject<ToastAction> = new Subject<ToastAction>();
private store$: Observable<Toast[]> = this.action$.pipe(
map((d: ToastAction) => (!d.payload.id) ? this.addId(d) : d),
mergeMap((d: ToastAction) => (d.type !== ToastActionType.Remove) ? this.addAutoExpire(d) : of(d)),
scan(this.reducer, [])
);
constructor() { }
public enqueueSuccess(message: string): void {
this.action$.next({
type: ToastActionType.Add,
payload: {
message,
status: ToastStatus.Success
}
});
}
public enqueueError(message: string): void {
this.action$.next({
type: ToastActionType.Add,
payload: {
message,
status: ToastStatus.Error
}
}
);
}
public enqueueInfo(message: string): void {
this.action$.next({
type: ToastActionType.Add,
payload: {
message,
status: ToastStatus.Info
}
});
}
public enqueueWarning(message: string): void {
this.action$.next({
type: ToastActionType.Add,
payload: {
message,
status: ToastStatus.Warning
}
});
}
public enqueueHide(payload: Toast): void {
this.action$.next({
type: ToastActionType.Remove,
payload
});
}
private addId(d: ToastAction): ToastAction {
return ({
type: d.type,
payload: { ...d.payload, id: this.generateId() }
});
}
private addAutoExpire(d: ToastAction) {
const signal$ = of(d);
const hide$ = of({ type: ToastActionType.Remove, payload: d.payload }).pipe(delay(this.config.duration));
return concat(signal$, hide$);
}
private generateId(): string {
return '_' + Math.random().toString(36).substr(2, 9);
}
private reducer(state: Toast[] = [], action: ToastAction): Toast[] {
switch (action.type) {
case ToastActionType.Add: {
return [action.payload, ...state];
}
case ToastActionType.Remove: {
return state.filter((toast: Toast) => toast.id !== action.payload.id);
}
default: {
return state;
}
}
}
}
Now our component has a much easier time calling our service:
// toast.component.ts
export class ToastComponent {
public state$: Observable<Toast[]> = this.toastService.store;
constructor(public toastService: ToastService) { }
public add() {
this.toastService.enqueueInfo('It works!');
this.toastService.enqueueWarning('Oops!');
this.toastService.enqueueError('Uh oh!');
this.toastService.enqueueSucccess(':D');
}
...
<!-- toast.component.html -->
<button
*ngFor="let toast of (state$ | async)"
(click)="remove(toast)">
<span [innerHtml]="toast.message"></span>
</button>
And what you get are many toast notifications with a powerful service with a very nice API.
Top comments (0)