Advanced State Management using ngrx v6

by Gerard Sans |  @gerardsans

Google Developer Expert

Google Developer Expert

International Speaker

Spoken at 93 events in 25 countries

Blogger

Blogger

Community Leader

900

1.5K

Trainer

Master of Ceremonies

Master of Ceremonies

FREE 3h Workshop

bit.ly/cfp-odessajs

NGRX

  • v6.0.1 (Dec 2015)
  • 122 contributors
  • CLI integration
  • 3K stars
  • v3.1.4 (Jan 2018)
  • 59 contributors
  • Plugins
  • 1K stars
  • v4 (June 2015)
  • 596 contributors
  • Inspired by Flux
  • 42K stars

Overview

  • State Management for Angular
  • Inspired by Redux
  • Implemented using RxJS
  • Angular CLI integration via schematics

@ngrx/store life cycle

source: blog

1

2

3

4

5

actions

store

A

A

A

S

S

S

Components

Actions

Reducer

Match

Dispatch

Creates

State

Notify

Packages

@ngrx/store-devtools

@ngrx/store

@ngrx/schematics

@ngrx/router-store

@ngrx/effects

@ngrx/entity

Features

Utilities

Counter

Increment

Decrement

Reset

Total

Components

Actions

Reducer

Match

Dispatch

Creates

State

Notify

import { Action } from '@ngrx/store';
export enum CounterActionTypes {
  Increment = '[Counter] Increment',
}
export class CounterIncrement implements Action {
  readonly type = CounterActionTypes.Increment;
}
export type CounterActions = CounterIncrement;
src/app.component.ts

Increment Action

app/actions/counter.actions.ts
> new CounterIncrement(); 
{ 
  type: "[Counter] Increment"
}
export function reducer(state = 0, action: CounterActions): number 
{
  switch (action.type) {
    case CounterActionTypes.Increment: 
      return state + 1;
    default:
      return state;
  }
}
src/app.component.ts

Counter Reducer

app/reducers/counter.reducer.ts
> reducer(undefined, { type: "@ngrx/store/init" })
0
> reducer(0, new CounterIncrement()) 
1
export enum CounterActionTypes {
  Reset = '[Counter] Reset'
}
export class CounterReset implements Action {
  readonly type = CounterActionTypes.Reset;
  constructor(public payload: { value: number }) { }
}
export type CounterActions = CounterIncrement | CounterReset;
src/app.component.ts

Reset Action

app/actions/counter.actions.ts
> new CounterReset({ value: 0 }); 
{ 
  type: "[Counter] Reset",
  payload: { value: 0 }
}
export function reducer(state = 0, action: CounterActions): number 
{
  switch (action.type) {
    case CounterActionTypes.Reset: 
      return action.payload.value;  
    default:
      return state;
  }
}
src/app.component.ts

Counter Reducer

app/reducers/counter.reducer.ts
> reducer(42, new CounterReset({ value: 0 }); 
0

Components

Actions

Reducer

Match

Dispatch

Creates

State

Notify

@Component({
  template: `
    <app-counter
      (increment)="increment()" (decrement)="decrement()"
      (reset)="reset()">
    </app-counter>`
})
export class AppComponent  {
  constructor(private store: Store) { }
  increment = () => this.store.dispatch(new CounterIncrement());
  decrement = () => this.store.dispatch(new CounterDecrement());
  reset = () => this.store.dispatch(new CounterReset({ value: 0 }));
}
src/app.component.ts

Dispatching Actions

app/app.component.ts

We pass events up to container

@Component({
  template: `
    <app-counter [total]="counter|async"></app-counter>`
})
export class AppComponent  {
  counter: Observable<number>;

  constructor(private store: Store<fromStore.State>) {
    this.counter = this.store.select(state => state.counter);
  }
}
src/app.component.ts

Subscribing to the Store

app/app.component.ts

Maps value

not Observable

We will improve this part later

@Component({ 
  selector: 'app-counter',
  template: `
    <div class="total">{{total}}</div>
    <button (click)="increment.emit()">+</button>
    <button (click)="decrement.emit()">-</button>
    <button (click)="reset.emit()">C</button>`,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class CounterComponent {
  @Input() total: number;
  @Output() increment = new EventEmitter();
  ...
}
src/app.component.ts

Counter

app/counter/counter.component.ts

better performance

Components

Actions

Reducer

Match

Dispatch

Creates

State

Notify

import * as fromCounter from './counter.reducer';

export interface State {
  "counter": number;
}

export const reducers: ActionReducerMap<State> = {
  "counter": fromCounter.reducer,
};
src/app.component.ts

Counter State

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

@NgModule({
  imports: [
    BrowserModule,
    StoreModule.forRoot(reducers),
  ],
})
export class AppModule { }
src/app.component.ts

Store Setup

app/app.module.ts

Reusing reducers

{ 
  counter: 7
}

Single Counter

{ 
  type: "[Counter] Increment"
}

State

Action

User clicks

{ 
  counter: 8
}

Multiple Counters

{ 
  "counter-1": 7,
  "counter-2": 3,
}
{ 
  type: "[Counter] Decrement",
  payload: { target: "counter-2" }
}

counter-1

counter-2

State

Action

User clicks

{ 
  "counter-1": 7,
  "counter-2": 2,
}
import * as fromCounter from './counter.reducer';
import { namedReducer } from './named.reducer';

export interface State {
  "counter-1" : number;
  "counter-2" : number;
}

export const reducers: ActionReducerMap<State> = {
  "counter-1": namedReducer(fromCounter.reducer, "counter-1"),
  "counter-2": namedReducer(fromCounter.reducer, "counter-2")
};
src/app.component.ts

Counter State

app/reducers/index.ts
export function namedReducer(reducer: any, target: string) {
  return (state: number, action: CounterActions) => {
    // ignore action and return current state
    if (action.payload && action.payload.target != target)
      return state;
    // otherwise use original reducer
    return reducer(state, action);
  }
}
src/app.component.ts

Meta-reducer

app/reducers/named.reducer.ts
namedReducer(fromCounter.reducer, "counter-2")
{ 
  type: "[Counter] Decrement"
  payload: { target: "counter-2" }
}

Selectors

State

Property Selectors

Computed Selectors

s

s

s

Property Selectors

  • Helpers to access Store

  • Avoid Components tight coupling with Store

  • Colocated with Reducers

store.select(state => state.total);
store.select(state => state.pagination.page);

Tight coupling

Components

State

Selectors: loose coupling

store.select(getCounter());
store.select(getPage());

Components

Selectors

State

A small change  won't break all Components now

// counter
export const getCounter = 
  (state: State): number => state.counter;

// todos
export const getTodos = 
  (state: State): Array<Todo> => state.todos;
export const getFilter = 
  (state: State): string => state.currentFilter;
src/app.component.ts

Property Selectors

app/reducers/index.ts

State

Property Selectors

Computed Selectors

s

s

s

Computed Selectors

  • Compute values using other Selectors

    • ​​createSelector, createFeatureSelector

  • Memoised for performance

  • Colocated with Reducers

// State
{ 
  todos: [{ id: 1, text: 'Learn ngrx', complete: false }],
  currentFilter: "SHOW_ALL"
}

// Visible Todos
[{ id: 1, text: 'Learn ngrx', complete: false }]
src/app.component.ts

Example: Visible Todos Selector

// State
{ 
  todos: [{ id: 1, text: 'Learn ngrx', complete: false }],
  currentFilter: "SHOW_COMPLETED"
}

// Visible Todos
[]

createSelector

export const getTodos = state => state.todos;
export const getFilter = state => state.currentFilter;

export const getVisibleTodos = createSelector(
  getTodos,
  getFilter,
  projector
);
export const getTodos = state => state.todos;
export const getFilter = state => state.currentFilter;

export const getVisibleTodos = createSelector(
  getTodos,
  getFilter,
  (todos, filter) => visibleTodos(todos, filter)
);
export const getTodos = state => state.todos;
export const getFilter = state => state.currentFilter;

export const getVisibleTodos = createSelector(
  getTodos,
  getFilter,
  visibleTodos
);

The projector function is just the calculation we want to run

Refactored! 

todos is  getTodos(state)

filter is getFilter(state)

export const getTodos = state => state.todos;
export const getFilter = state => state.currentFilter;

Using createSelector

app/reducers/index.ts
src/app.component.ts

Selector: Visible Todos

function visibleTodos(todos: Array<Todo>, filter: string) {
  switch (filter) {
    case 'SHOW_ACTIVE':
      return todos.filter(t => !t.completed);
    case 'SHOW_COMPLETED':
      return todos.filter(t => t.completed);
    case 'SHOW_ALL':
    default: 
      return todos;
  }
};
import { getVisibleTodos } from './reducers'

@Component({
  template: `<todo *ngFor="let todo of todos | async"></todo>`
})
export class TodoComponent  {
  todos: Observable<Array<Todo>>;

  constructor(private store: Store<fromStore.State>) {
    this.todos = this.store.select(getVisibleTodos);
  }
}
src/app.component.ts

Using getVisibleTodos

app/todos.component.ts

createFeatureSelector

export const getBooksState = 
  createFeatureSelector<BooksState>('books');

// { 
//   search: ...
//   books: ...
//   collection: ...
// }

This is always from the Root

Using createFeatureSelector

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

@NgModule({
  imports: [
    CommonModule,
    StoreModule.forFeature('books', reducers),
  ],
})
export class BooksModule { }
src/app.component.ts

Store Setup

app/books/books.module.ts

Books Module can be lazy loaded

Memoisation

Memoisation

  • Created to improve performance of Machine Learning algorithms

  • Targets repetitive calls trading space for speed

  • Uses a Cache to store Results

  • 1-slot and multi-slot

src/app.component.ts

1-slot Memoisation

function memoise(fn) {
  let cache, $args;
  return function() {
    if (sameArgs($args, arguments)){
      console.log('cached');
      return cache;
    } else {
      console.log('calculating...');
      cache = fn(...arguments);
      $args = arguments;
      return cache;
    }
  }
};
const add = (a,b) => a+b;
> $add(1,1)
calculating...
2

first execution

we  keep results and arguments

> const $add = memoise(add);
> $add(1,1)
cached
2

next execution

with same arguments

we  use cache

src/app.component.ts

1-slot Memoisation Limitations

> $add(1,1)
calculating...
2
> $add(2,2)
calculating...
4
> $add(1,1)
calculating...
2

Unless we do consecutive repetitive calls (recursive) is not very effective

😱

src/app.component.ts

Multi-slot Memoisation

function memoise(fn) {
  let cache = {};
  return function() {
    const key = arguments.join('-');
    if (key in cache) {
      return cache[key];
    }
    else {
      cache[key] = fn(...arguments);
      return cache[key];     
    }
  }
};
const add = (a,b) => a+b;

we use the key  1-1 to cache  results

> $add(1,1)
calculating...
2
> $add(2,2)
calculating...
4
> $add(1,1)
cached
2

we create a unique key using the arguments

 Use JSON.stringify for a  generic approach

cache

{"1-1": 2, "2-2": 4}

this time

we hit the cache

😃

@ngrx/store Selectors

  • Uses 1-slot Memoisation

    • ​createSelector, createFeatureSelector

  • Applied to projector​ function
  • Allows replacing default memoisation
    • reselect (1-slot)
    • moize (multi-slot)
src/app.component.ts

1-slot Memoisation: getVisibleTodos

> const state = { 
  todos: [{ id: 1, text: 'Learn ngrx', complete: false }],
  currentFilter: "SHOW_ALL"
};
[{ id: 1, text: 'Learn ngrx', complete: false }]
> getVisibleTodos(state);

filter function executed

[]
> state = { ...state, currentFilter: "SHOW_COMPLETED" }
> getVisibleTodos(state);
> state = { ...state, currentFilter: "SHOW_ALL" }
> getVisibleTodos(state);
[{ id: 1, text: 'Learn ngrx', complete: false }]

executed again

and again...

😱

import { createSelectorFactory } from '@ngrx/store';
import moize from "moize";

const customMemoizer = fn => {
  const memoized = moize.deep(fn);
  return { memoized, reset: () => memoized.clear() }
};
const $createSelector = createSelectorFactory(customMemoizer);

export const getFilteredTodos = $createSelector(
  selectTodosEntities,
  selectCurrentFilter,
  getVisibleTodos
); 
src/app.component.ts

Multi-slot getVisibleTodos

We can  use deepEqual for arguments

reset will clear

 the cache

src/app.component.ts

Multi-slot Memoisation: getVisibleTodos

> const state = { 
  todos: [{ id: 1, text: 'Learn ngrx', complete: false }],
  currentFilter: "SHOW_ALL"
};
[{ id: 1, text: 'Learn ngrx', complete: false }]
> getVisibleTodos(state);

filter function executed

[]
> state = { ...state, currentFilter: "SHOW_COMPLETED" }
> getVisibleTodos(state);
> state = { ...state, currentFilter: "SHOW_ALL" }
> getVisibleTodos(state);
[{ id: 1, text: 'Learn ngrx', complete: false }]

executed again

Cached!

😃

More

@MikeRyanDev

@robwormald

@brandontroberts

Rob Wormald

Mike Ryan

Brandon Roberts

@toddmotto

Todd Motto