Selectors & Memoization in ngrx v6

by Gerard Sans |  @gerardsans

Google Developer Expert

Google Developer Expert

International Speaker

Spoken at 95 events in 25 countries

Blogger

Blogger

Community Leader

900

1.5K

Trainer

Master of Ceremonies

Master of Ceremonies

NGRX

  • v6.0.1 (Dec 2015)
  • 125 contributors
  • CLI integration
  • 3K stars
  • v3.2 (Jan 2018)
  • 66 contributors
  • Plugins
  • 1K stars
  • v4 (June 2015)
  • 603 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