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