Unlocking the NgRx Platform

Gerard Sans

@gerardsans

Gerard Sans

@gerardsans

Unlocking the NgRx Platform

SANS

GERARD

Google Developer Expert

Google Developer Expert

International Speaker

Spoken at 103 events in 27 countries

Blogger

Blogger

Community Leader

900

1.6K

Trainer

Master of Ceremonies

Master of Ceremonies

#cfpwomen

ngrx

Rob Wormald

@robwormald

@ngrx/platform

  • Suite including
    • @ngrx/store
    • @ngrx/effects
    • @ngrx/router-store
    • @ngrx/store-devtools
    • @ngrx/entity
    • @ngrx/schematics

Benefits

Why use ngrx?

  • Developer Experience
    • Predictability
    • Traceability
    • Automation
  • Performance
  • Testability

ngrx/store

ngrx/store

  • Re-implementation of Redux on top Angular and RxJS
  • Colocation Support
    • StoreModule.forRoot()
    • StoreModule.forFeature()
  • Supports Lazy Load
  • Type safety

@ngrx/store life cycle

source: blog

1

2

3

4

5

actions

store

A

A

A

S

S

S

ngrx/store Setup

import { StoreModule } from '@ngrx/store';
import { reducers } from './reducers';


@NgModule({
  imports: [ 
     StoreModule.forRoot(reducers),
  ]
})
export class AppModule {}

Actions

  • Actions may have a payload
  • Asynchronous actions are called effects
  • Typed Actions

Action Examples

{ 
  type: '[Counter] Increment', 
}

{ 
  type: '[Todo] Add Todo', 
  payload: { 
    id: 1, 
    text: 'Learn French', 
    completed: false 
  } 
}

Action Creators

  • Enum declares each action type
  • Each action has a supporting class
  • Action type is a readonly property
  • If needed, payload is handled in the constructor

Action Creators Example

import { Action } from '@ngrx/store';

export enum ActionTypes {
  Increment = '[Counter] Increment',
  Reset = '[Counter] Reset',
}

export class Increment implements Action {
  readonly type = ActionTypes.Increment;
}

export class Decrement implements Action {
  readonly type = ActionTypes.Reset;
  constructor(public payload: { value: number });
}

export type Actions = Increment | Reset;

Action Creators Example

import * as counter from './counter.actions';

const action = new counter.Increment();

// {
//   type: '[Counter] Increment'
// }


const action = new counter.Reset({ value: 0 });

// {
//   type: '[Counter] Reset'
//   payload: {
//     value: 0
//   }
// }

Reducers

  • Same as in Redux
  • Typed Actions and State
  • Composable using ActionReducerMap<State>

Example: reducer (1/2)

// counter.reducer.ts
export const initialState: number = 0;
export function reducer(
  state = initialState, 
  action: CounterActions
): number {
  switch (action.type) {
    // add your actions here
    default:
      return state; // forward state to next reducer
  }
}

Example: reducer (2/2)

// counter.reducer.ts
export function reducer(state = 0, action:CounterActions): number {
  switch (action.type) {
    case CounterActionTypes.Increment: return state + 1;
    case CounterActionTypes.Decrement: return state - 1; 
    default:
      return state;
  }
}
// execution
reducer(undefined, { type:'@ngrx/store/init' }) // { counter: 0 }

store.dispatch( new counter.Increment() );
reducer(0, { type:'[Counter] Increment' })      // { counter: 1 }

Containers

Container Component

// loading.component.ts
import { Component, OnInit } from '@angular/core';
import { Store } from '@ngrx/store';
import * as fromStore from '../reducers/index';

@Component({
  selector: 'app-loading',
  templateUrl: './loading.component.html',
  styleUrls: ['./loading.component.css']
})
export class LoadingComponent implements OnInit {
  constructor(private store: Store<fromStore.State>) { }
  ngOnInit() { }
}

Basic Selector

import { Observable } from 'rxjs/Observable';
import { map } from 'rxjs/operators';

@Component({
  template: `<div *ngIf="loading | async"><svg></svg></div>`
})
export class LoadingComponent implements OnInit {
  loading: Observable<boolean>; 
  ngOnInit() { 
    this.loading = this.store.pipe(
      map(state => state.loading)
    );
  }
}

select Example

import { Observable } from 'rxjs/Observable';

@Component({
  template: `<div *ngIf="loading | async"><svg></svg></div>`
})
export class LoadingComponent implements OnInit {
  loading: Observable<boolean>; 
  ngOnInit() { 
    this.loading = this.store.select('loading');
    // this.loading = this.store.select(state => state['loading']);
    // this.loading = this.store.select(state => state.loading);
  }
}

ngrx/store-devtools

ngrx/store-devtools

  • Redux DevTools for ngrx
  • Live debugging
  • State and Actions tracking
  • Dispatch Actions
  • Save/Restore State
  • Time Travel

ngrx/store-devtools Setup

import { StoreDevtoolsModule } from '@ngrx/store-devtools';

@NgModule({
  imports: [
    StoreModule.forRoot(reducers),
    StoreDevtoolsModule.instrument(),
  ]
})
export class AppModule {}

Instrumentation Options

  • Set Inspector name
  • Set maximum history
  • Implement Custom Monitor
    • @ngrx/store-log-monitor

  • Enable State/Action Sanitizers
  • Disable features

Instrumentation Example

import { StoreDevtoolsModule } from '@ngrx/store-devtools';

@NgModule({
  imports: [
    StoreModule.forRoot(reducers),
    StoreDevtoolsModule.instrument({
      name: 'my-app',
      maxAge: 25, 
      features: { pause: false, export: false, jump: false, test: true },
    }),
  ]
})
export class AppModule {}

extension.remotedev.io

ngrx/schematics

ngrx/schematics

  • Scaffolding templates for ngrx
  • Integrates with Angular CLI
  • Provides commands for
    • Setting initial Store and Effects
    • Actions, Reducers, Entities, Features
    • Containers + Store injected
  • Automates creation + registration

ngrx/schematics Setup

> npm install @ngrx/schematics --save-dev

> ng generate $type $options --collection @ngrx/schematics

> ng set defaults.schematics.collection=@ngrx/schematics

> ng generate $type $options

Blueprints ngrx

> ng generate $type $options

> $type: store st, feature f, effect ef, container co
         reducer r, action a, entity en 

> $options: --flat (default) --group --spec

Initial Store Setup

> $project: ngrx-basic
> $state: State

> ng new $project

> npm install @ngrx/schematics --save-dev
> npm install @ngrx/{store, effects, entity, store-devtools} --save

> ng generate store $state --root --module app.module.ts

New Action Example

> $name: counter

// same folder no specs
> ng generate action $name
create src/app/counter.actions.ts

// separate actions folder + specs
> ng generate action $name --group --spec
create src/app/actions/counter.actions.ts
create src/app/actions/counter.actions.spec.ts 

// same folder + specs
> ng generate action $name --spec
create src/app/counter.actions.ts
create src/app/counter.actions.spec.ts 

counter.actions.ts

import { Action } from '@ngrx/store';

export enum CounterActionTypes {
  CounterAction = '[Counter] Action'
}

export class Counter implements Action {
  readonly type = CounterActionTypes.CounterAction;
}

export type CounterActions = Counter;

Example

Setup

import { App } from './app';
import { StoreModule } from "@ngrx/store";
import { rootReducer } from './rootReducer';

@NgModule({
  imports: [ 
    BrowserModule, 
    StoreModule.forRoot(rootReducer)
  ],
  declarations: [ App ],
  bootstrap: [ App ]
})
export class AppModule {}

platformBrowserDynamic().bootstrapModule(AppModule);

Adding a new Todo

  1. Component subscribes
  2. Component dispatches ADD_TODO action
  3. Store executes rootReducer
  4. Store notifies Component
  5. View updates

1

2

3

4

5

Subscribing to the Store

@Component({
  template: 
      `<todo *ngFor="let todo of todos | async">{{todo.text}}</todo>`
})

export class App implements OnDestroy {
  public todos: Observable<Todo>;
  
  constructor(
    private _store: Store<TodosState>
  ) {
    this.todos = _store.let(
      state$ => state$.select(s => s.todos); 
    );
  }
}

ADD_TODO Action

// add new todo
{
  type: ADD_TODO,
  id: 1,
  text: "learn redux",
  completed: false
}

todos Reducer

const initialState = [];

const todos = (state = initialState, action:Action) => {
  switch (action.type) {
    case TodoActions.ADD_TODO: 
      return state.concat({ 
          id: action.id,
          text: action.text,
          completed: action.completed });
    default: return state;
  }
}

// {
//  todos: [], <-- todos reducer will mutate this key
//  currentFilter: 'SHOW_ALL'
// }

currentFilter Reducer

const currentFilter = (state = 'SHOW_ALL', action: Action) => {
  switch (action.type) {
    case TodoActions.SET_CURRENT_FILTER:
      return action.filter
    default: return state;
  }
}

// {
//  todos: [],
//  currentFilter: 'SHOW_ALL' <-- filter reducer will mutate this key
// }

rootReducer

import { ActionReducerMap } from '@ngrx/store';

interface Todo { 
  id: number,
  text: string,
  completed: boolean
}

export interface TodosState {
  todos: Array<Todo>;
  currentFilter: string;
}

export const rootReducer: ActionReducerMap<TodosState> = {  
  todos: todos,
  currentFilter: currentFilter
};

New State

{
  todos: [{
    id: 1,
    text: "learn redux",
    completed: false
  }],
  currentFilter: 'SHOW_ALL'
}

ngrx/effects

ngrx/effects

  • Asynchronous Actions Middleware
  • Executes side-effects
  • Colocation Support
    • EffectsModule.forRoot()
    • EffectsModule.forFeature()

Asynchronous Actions

source: blog

1

2

3

4

5

A

A

ngrx/effects Setup

import { EffectsModule } from '@ngrx/effects';
import { todoEffects } from './todoEffects';

@NgModule({
  imports: [ 
    StoreModule.forRoot(rootReducer),
    EffectsModule.forRoot([todoEffects]),
  ]
})
export class AppModule {}

Effects Service

import { Actions, Effect, ofType } from '@ngrx/effects';
import { TodoActions, ADD_TODO_EFFECT } from './todoActions';

@Injectable()
export class todoEffects {
  constructor(
    private actions$ : Actions,
    private actions : TodoActions ) { }

  @Effect() 
  addTodoAsync$ = this.actions$
    .ofType(ADD_TODO_EFFECT)
    .mergeMap(action => this.http.post('/todo', action.text)
      .map(response => ({ 
          type: 'ADD_TODO_SUCCESS', 
          text: text   
        }))
    )
}

Add new todo async

// Dispatch 
private addTodoAsync(input) {
  this._store.dispatch({
    type: ADD_TODO_EFFECT
    text: input.value
  });
  input.value = '';
} 

// Middleware
@Effect() 
addTodoAsync$ = this.actions$
  .ofType(ADD_TODO_EFFECT)
  .mergeMap(action => this.http.post('/todo', action.text)
    .map(response => ({ 
        type: 'ADD_TODO_SUCCESS', 
        text: text   
      }))
  )

Error Handling

@Injectable()
export class todoEffects {
  @Effect() 
  addTodoAsync$ = this.actions$
    .ofType(ADD_TODO_EFFECT)
    .mergeMap(action => this.http.post('/todo', action.text)
      .map(response => ({ 
          type: 'ADD_TODO_SUCCESS', 
          text: text   
        }))
      .catch(error => Observable.of({ 
          type: 'ADD_TODO_FAILED', 
          error: { message: error }  
        })
      )
    )
}

ngrx/router-store

ngrx/router-store

  • Bindings for Angular Router
  • Router emits Actions
    • ROUTER_NAVIGATION
    • ROUTER_CANCEL
    • ROUTER_ERROR
  • Available to Reducers and Effects
  • Throwing errors cancel navigation

ngrx/router-store Setup

import { StoreRouterConnectingModule, 
  RouterStateSerializer } from '@ngrx/router-store';
import { CustomRouterStateSerializer } from './utils/CustomRouterStateSerializer';

@NgModule({
  imports: [
    StoreModule.forRoot(reducers),
    StoreRouterConnectingModule.forRoot({ stateKey: 'router' }),
 ],
  providers: [
    { provide: RouterStateSerializer, useClass: CustomRouterStateSerializer }
  ],
})
export class AppModule { }

Router Reducer Setup

// reducers/index.ts
import { RouterStateUrl } from '../utils/CustomRouterStateSerializer';
import * as fromRouter from '@ngrx/router-store';

export interface State {
  router: fromRouter.RouterReducerState<RouterStateUrl>;
}

export const reducers : ActionReducerMap<State> = {
  router: fromRouter.routerReducer
} 

Custom Router Serializer

{
  type: 'ROUTER_NAVIGATION',
  payload: {
    routerState: {
      url: '/users/34;flag=true?q=1',
      params: {
        id: '34',
        flag: 'true'
      },
      queryParams: {
        q: '1'
      }
    },
    event: { /* previous routerState */ }
  }
}

Enabling

Router Actions

Router Actions

import { Store } from '@ngrx/store';
import * as fromStore from './reducers/index';
import * as router from './actions/router.actions';

export class ContainerComponent { 
  constructor(private store: Store<fromStore.State>) {

    this.store.dispatch(new router.Go({
      path: ['/users', 34, { flag: true }], 
      query: { q: 1 },
      extras: { replaceUrl: false }
    }));

    this.store.dispatch(new router.Back());
    this.store.dispatch(new router.Forward());
  }
}

Router Actions Setup (1/2)

import { NavigationExtras } from '@angular/router';

export enum RouterActionTypes {
  Go = '[Router] Go',
  Back = '[Router] Back',
  Forward = '[Router] Forward'
}

export interface NavigationUrl {
  path: any[];
  query?: object;
  extras?: NavigationExtras;
}

export class Go implements Action {
  readonly type = RouterActionTypes.Go;
  constructor(public payload: NavigationUrl) {}
}

Router Actions Setup (2/2)

export class Back implements Action {
  readonly type = RouterActionTypes.Back;
}

export class Forward implements Action {
  readonly type = RouterActionTypes.Forward;
}

export type Actions = Go | Back | Forward;

Router Effects Setup (1/2)

@Injectable()
export class RouterEffects {
  constructor(private actions$: Actions, private router: Router, 
    private location: Location) {}
  
  @Effect({ dispatch: false })
  navigate$ = this.actions$.pipe(
    ofType(RouterActionTypes.Go),
    pluck('payload'),
    tap(({ path, query: queryParams, extras}) => 
      this.router.navigate(path, { queryParams, ...extras }))
  )

  @Effect({ dispatch: false })
  navigateBack$ = this.actions$.pipe(
    ofType(RouterActionTypes.Back),
    tap(() => this.location.back())
  );
}

Router Effects Setup (2/2)

import { EffectsModule } from '@ngrx/effects';
import { RouterEffects } from './effects/router.effects'

@NgModule({
  imports: [
    StoreModule.forRoot(reducers),
    StoreRouterConnectingModule.forRoot({ stateKey: 'router' }),
    EffectsModule.forRoot([RouterEffects]),
 ],
  providers: [
    { provide: RouterStateSerializer, useClass: CustomRouterStateSerializer }
  ],
})
export class AppModule { }

ngrx/entity

ngrx/entity

  • Library to manage Lists
  • High performance
    • EntityState<T>
    • Ids lookup and Entities Map
  • Reducer Helper (EntityAdapter)
    • getInitialState
    • Add/update/remove: All, One, Many
    • getSelectors: Ids, Entities, All, Total

ngrx/schematics Entity

> $name: todo

> ng generate entity $name --module app.module.ts
create src/app/todo.actions.ts
create src/app/todo.model.ts
create src/app/todo.reducer.ts 
create src/app/todo.reducer.spec.ts 
update src/app/app.module.ts 

Todos Actions

// src/app/reducers/todo/todo.actions.ts
export enum TodoActionTypes {
  AddTodo = '[Todo] Add Todo', UpdateTodo = '[Todo] Update Todo', ...
}

let currentId = 1;
export class AddTodo implements Action {
  readonly type = TodoActionTypes.AddTodo;
  constructor(public payload: { todo: Todo }) {
    payload.todo.id = currentId++;
  }
}

export type TodoActions = LoadTodos | AddTodo | UpsertTodo | AddTodos 
| UpsertTodos | UpdateTodo | UpdateTodos | DeleteTodo | DeleteTodos | ClearTodos;

Todos Model

// src/app/reducers/todo/todo.model.ts
import { EntityState } from '@ngrx/entity';

export interface Todo {
  id: number;
  completed: boolean;
  text: string;  
}

export interface Todos {
  todos: EntityState<Todo>;
}
{
  todos: [
    {
      id: 1,
      text: 'Learn ngrx',
      completed: false
    }
  ],
  currentFilter: 'SHOW_ALL'
}
{
  todos: {
    ids: [
      1
    ],
    entities: {
      '1': {
        id: 1,
        text: 'Learn ngrx',
        completed: false
      }
    }
  },
  currentFilter: 'SHOW_ALL'
}

todo: Array<Todo>

todo: EntityState<Todo>

Todo Reducer

// src/app/reducers/todo/todo.reducer.ts
export const adapter: EntityAdapter<Todo> = createEntityAdapter<Todo>();

export function reducer(
  state = initialState,
  action: TodoActions
): State {
  switch (action.type) {
    case TodoActionTypes.AddTodo: {
      return adapter.addOne(action.payload.todo, state);
    }
    default: 
      return state;
  }
}
export const { 
  selectIds, selectEntities, selectAll, selectTotal 
} = adapter.getSelectors();

Setting initialState

// src/app/reducers/todo/todo.reducer.ts
export const adapter: EntityAdapter<Todo> = createEntityAdapter<Todo>();

export let initialState: State = adapter.getInitialState({
  // additional entity state properties
});

initialState = adapter.addMany([
  { id: -2, text: 'Learn ngrx', completed: true },
  { id: -1, text: 'Try Cherry Liquor 😋', completed: false }
], initialState);

Filter Actions

// src/app/reducers/currentFilter/currentFilter.actions.ts
import { Action } from '@ngrx/store';

export enum CurrentFilterActionTypes {
  SetCurrentFilter = '[Filter] Set current filter'
}

export class SetCurrentFilter implements Action {
  readonly type = CurrentFilterActionTypes.SetCurrentFilter;

  constructor(public payload: { filter: string }) {}
}

export type CurrentFilterActions = SetCurrentFilter;

Filter Reducer

// src/app/reducers/currentFilter/currentFilter.reducer.ts
export const initialState: string = 'SHOW_ALL';

export function reducer(
  state = initialState, 
  action: CurrentFilterActions): string 
{
  switch (action.type) {
    case CurrentFilterActionTypes.SetCurrentFilter:
      return action.payload.filter
    default:
      return state;
  }
}

Composing Reducers

import * as todos from './todo/todo.reducer';
import * as currentFilter from './currentFilter/currentFilter.reducer';

export interface Filter { 
  currentFilter: string;
}
export interface Todos {
  todos: EntityState<Todo>;
}
export interface TodosState extends Todos, Filter { }

export const reducers: ActionReducerMap<TodosState> = {
  todos: todos.reducer,
  currentFilter: currentFilter.reducer
};

Selectors

export const selectTodos = (state: TodosState) => state.todos;
export const selectCurrentFilter = (state: TodosState) => state.currentFilter;

export const selectTodosEntities = createSelector(
  selectTodos,
  todoEntity.selectAll    // (todos: EntityState<Todo>): Todo[]
);

export const getFilteredTodos = createSelector(
  selectTodosEntities,
  selectCurrentFilter,
  getVisibleTodos         // (todos: Todo[], filter: string): Todo[]
); 

Container

import { TodosState, getFilteredTodos } from './reducers'

@Component({
  template: `<todo *ngFor="let todo of todos | async"></todo>` 
})
export class AppComponent implements OnInit {
  todos: Observable<Todo[]>;

  constructor(private store: Store<TodosState>) { }

  public ngOnInit() {
    this.todos = this.store.select(getFilteredTodos);
  }
}

AppModule Setup

import { StoreModule } from "@ngrx/store";
import { StoreDevtoolsModule } from '@ngrx/store-devtools';
import { AppComponent } from './app.component';
import { reducers } from './reducers';

@NgModule({
  imports: [ 
    BrowserModule, 
    StoreModule.forRoot(reducers),
    !environment.production ? StoreDevtoolsModule.instrument() : [],
  ],
  declarations: [ AppComponent ],
  bootstrap: [ AppComponent ]
})
export class AppModule {}

Best Practices

Project Structure

  • Create folders to group files
    • ​by category (actions, effects, reducers)
    • by feature (search, auth)

Component Types

  • Stateful
    • Smart Components
    • Containers
  • Stateless
    • Dumb Components
    • Presentational

New Paradigms

  • ​Containers vs Presentational
  • ​Containers
    • Subscribe to the Store
    • Dispatch Actions
  • Effects for server-side

Service

Component

Service

Component

Service

Component

Service

Component

Service

Component

Service

Component

Service

Component

Component

Component

Container

Presentational

<todo-app>

<todo-list [todos]="todos$ | async"></todo-list>

Top-down Communication

onPush

<todo-app                                                    (new)="newTodo($event)"

  (toggle)="toggleTodo($event)">

</todo-app>                                               

<add-todo></add-todo>

<todo-list></todo-list>

new

toggle

Events Delegation

Advanced ngrx

Meta  Reducers

MetaReducers

  • Execute before Reducers
  • High order function
    • ActionReducer<State>
  • Used for logging, storage
  • Composable using
    • ​ActionReducer<State>[]

Metareducers Setup

// app.module.ts
import { StoreModule } from '@ngrx/store';
import { reducers, metaReducers } from './reducers';
@NgModule({
  imports: [ 
     StoreModule.forRoot(reducers, { metaReducers }),
  ]
})
export class AppModule {}

// reducers/index.ts
import { environment } from '../../environments/environment';
import { meta } from './meta.reducer';

export const metaReducers = !environment.production ? [meta] : [];

Example: metareducer (1/2)

// meta.reducer.ts
import { ActionReducer, Action } from '@ngrx/store';
import { State } from './index';

export function meta(
  reducer: ActionReducer<State>
): ActionReducer<State> {
  return function(state: State, action: Action): State {
    return reducer(state, action);
  };
} 

Example: metareducer (2/2)

// meta.reducer.ts
import { ActionReducer, Action } from '@ngrx/store';
import { State } from './index';

export function meta(
  reducer: ActionReducer<State>
): ActionReducer<State> {
  return function(state: State, action: Action): State {
    console.log('state', state);
    console.log('action', action);
    return reducer(state, action);
  };
} 

More

@MikeRyanDev

@robwormald

@brandontroberts

Rob Wormald

Mike Ryan

Brandon Roberts

@toddmotto

Todd Motto

Thanks

Thanks

Unlocking the NgRx platform

By Gerard Sans

Unlocking the NgRx platform

During this hands-on day we will cover all ngrx packages (v5) so you can master the whole ngrx platform including: store, effects, router, entity and devtools. You will learn how to integrate the ngrx platform in your current projects following best practices!

  • 3,687