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

🎉 100 🎉

🎉   🎉

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!

  • 4,025