DEV Community

Alex Fallenstedt
Alex Fallenstedt

Posted on • Edited on

Scan Operator For Mini Redux Stores

toaster

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.

toast

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;
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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
    });
  }
   ...

Enter fullscreen mode Exit fullscreen mode

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;
      }
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

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');
  }
   ...

Enter fullscreen mode Exit fullscreen mode
<!-- toast.component.html --> 
  <button 
    *ngFor="let toast of (state$ | async)"
    (click)="remove(toast)">
    <span [innerHtml]="toast.message"></span>
  </button>
Enter fullscreen mode Exit fullscreen mode

And what you get are many toast notifications with a powerful service with a very nice API.

Top comments (0)