Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
Using ngrx with Effects + Facades

NgRx State Management with TicketFacade

Facades are a programming pattern in which a simpler public interface is provided to mask a composition of internal, more-complex, component usages.

When writing a lot of NgRx code - as many enterprises do - developers quickly accumulate large collections of actions and selectors classes. These classes are used to dispatch and query [respectively] the NgRx Store.

Using a Facade - to wrap and blackbox NgRx - simplifies accessing and modifying your NgRx state by masking internal all interactions with the Store, actions, reducers, selectors, and effects.

For more introduction, see Better State Management with Ngrx Facades

Solution: API Facade

Using a Facade pattern to expose a clear, concise services API and encapsulate the use of Effects and NgRx is a wonderful solution:

import { Injectable } from '@angular/core';
import { ActivatedRoute } from '@angular/router';

import { Store } from '@ngrx/store';
import { Actions, Effect, ROOT_EFFECTS_INIT } from '@ngrx/effects';

import { Observable, forkJoin, of } from 'rxjs';
import { map, switchMap, combineLatest, withLatestFrom, mergeMap, concat, merge } from 'rxjs/operators';

import { BackendService } from '../services';
import { NoopAction, ApplicationState } from '../app/+state';
import { LoadAllUsersAction, UsersFacade } from '../users/+state';
import { Ticket, User } from '../ticket';

import { TicketAction, TicketActionTypes, TicketSelectedAction } from './tickets.actions';
import { TicketsQuery } from './tickets.reducers';
import {
  FilterTicketsAction,
  LoadAllTicketsAction,
  SaveTicketAction,
  AssignUserAction,
  CompleteTicketAction,
  TicketsLoadedAction,
  TicketSavedAction
} from './tickets.actions';

@Injectable()
export class TicketsFacade {
  /**
   * Public properties to centralize and expose store queries
   */
  users$           = this.users.allUsers$;
  filteredTickets$ = this.store.select(TicketsQuery.getTickets);
  selectedTicket$  = this.store.select(TicketsQuery.getSelectedTicket);

  constructor(
    private actions$: Actions,
    private store: Store<ApplicationState>,
    private users: UsersFacade,
    private backend: BackendService,
    private route: ActivatedRoute) {

    makeTicketID$(route).subscribe(ticketId => {
       this.select(ticketId);
    });
  }

  // ***************************************************************
  // Public API (normally seen in `ticket.service.ts`)
  //
  // Except these do not return anything... this only dispatch store actions.
  // Use the public observables above to watch for results/changes
  //
  // ***************************************************************

  loadAll()               { this.store.dispatch(new LoadAllTicketsAction());          }
  select(ticketId:string) { this.store.dispatch(new TicketSelectedAction(ticketId));  }
  add(title:string)       { this.store.dispatch(new SaveTicketAction({title}));       }
  close(ticket:Ticket)    { this.store.dispatch(new CompleteTicketAction(ticket));    }
  save(ticket:Ticket)     { this.store.dispatch(new SaveTicketAction(ticket));        }
  assign(ticket:Ticket)   { this.store.dispatch(new AssignUserAction(ticket));        }

  // ***************************************************************
  // Public API
  // Only one that returns the filteredList (that will be async populated)
  // ***************************************************************

  getTickets(): Observable<Ticket[]> {
    this.loadAll();
    return this.filteredTickets$;
  }

  // ***************************************************************
  // Private Queries
  // ***************************************************************

  private loaded$ = this.store.select(TicketsQuery.getLoaded);
  private allTickets$ = this.store.select(TicketsQuery.getAllTickets);

  // ***************************************************************
  // Effect Models
  //
  // These are run in the 'background' and are never exposed/used
  // by the view components
  //
  // ***************************************************************

  @Effect()
  autoLoadAllEffect$ = this.actions$
    .ofType(ROOT_EFFECTS_INIT)
    .pipe(
      mergeMap(_ => [
        new LoadAllUsersAction(),
        new LoadAllTicketsAction()
      ])
    );

  @Effect()
  loadAllEffect$ = this.actions$
    .ofType(TicketActionTypes.LOADALL)
    .pipe(
      withLatestFrom(this.loaded$),
      switchMap(([_, loaded]) => {
        return loaded ? of(null) : this.backend.tickets();
      }),
      map((tickets: Ticket[] | null) => {
        if (tickets) {
          tickets = this.users.updateWithAvatars(tickets);
          return new TicketsLoadedAction(tickets);
        }
        return new NoopAction();
      })
    );

  @Effect()
  saveEffect$ = this.actions$
    .ofType(TicketActionTypes.SAVE)
    .pipe(
      map(toTicket),
      map((ticket: Ticket) => new TicketSavedAction(ticket))
    );

  @Effect()
  completeEffect$ = this.actions$
    .ofType(TicketActionTypes.COMPLETE)
    .pipe(
      map(toTicket),
      switchMap(ticket => this.backend.complete(ticket.id, true)),
      map((ticket: Ticket) => new TicketSavedAction(ticket))
    );

  @Effect()
  addNewEffect$ = this.actions$
    .ofType(TicketActionTypes.CREATE)
    .pipe(
      map(toTicket),
      switchMap(ticket => this.backend.newTicket(ticket)),
      map((ticket: Ticket) => new TicketSavedAction(ticket))
    );

  @Effect()
  assignEffect$ = this.actions$
    .ofType(TicketActionTypes.ASSIGN)
    .pipe(
      map(toTicket),
      switchMap(({ id, assigneeId }) => this.backend.assign(id, assigneeId)),
      map((ticket: Ticket) => new TicketSavedAction(ticket))
    );

}

const toTicket = (action: TicketAction): Ticket => action.data as Ticket;
const ofType = (type: string) => (action): boolean => action.type == type;

/**
 * For the current route and for future route changes, prepare an Observable to the route
 * params ticket 'id'
 */
const makeTicketID$ = ( route: ActivatedRoute ):Observable<string> => {
  const current$ = of(route.snapshot.paramMap.get('id'));
  const future$ = route.params.pipe( map( params => params['id']));

  return current$.pipe(merge( future$ ));
};
@ThomasBurleson

This comment has been minimized.

Copy link
Owner Author

commented Aug 8, 2018

Using NgRx Facades with View Components

By Using NgRx Facades, the views no longer worry or even know about actions, reducers, REST services, effects, or even router navigation (when a ticket is selected).

A view component (aka Container) would simply inject and use the 'ticketFacade' as shown below:

ticket-editor.components.ts:
import {Component} from '@angular/core';
import {TicketsFacade} from './+state/tickets.facade';

@Component({
  selector: 'ticket-editor',
  template: `
      <div fxLayout fxLayoutAlign="center center" class="centered" @card>
        <ticket-card  [ticket]="service.selectedTicket$ | async"
                      [users]="service.users$ | async"
                      (save)="service.save($event)"
                      (complete)="service.close($event)"
                      (reassign)="service.assign($event)" >
        </ticket-card>
      </div>
      <a mat-fab title="Add a new ticket" class="floating-button" routerLink="/ticket/new" >
        <mat-icon class="md-24">add</mat-icon>
      </a>
   `
})
export class TicketEditorComponent {
  constructor(public service: TicketsFacade) { }
}

Notice how the presentation ticket-card child component has inputs of raw data (instead of observables).

From the TicketEditorComponent perspective, the facade service black-boxes (i) ALL state management and (ii) delegated user interactions.

Public API

The Facade service provides an explicit public API of:

  • Observable properties: users$, selectedTicket$
  • Public methods: save(), close(), assign()
@nisimjoseph

This comment has been minimized.

Copy link

commented Aug 26, 2018

Thank you I love the way it organizes and makes NGRX more sense and order.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
You can’t perform that action at this time.