Understanding Selector Pattern and Memoization in NgRx

Gerard Sans

@gerardsans

Gerard Sans

@gerardsans

Understanding Selector Pattern and Memoization in NgRx

SANS

GERARD

Google Developer Expert

Google Developer Expert

International Speaker

Spoken at 102 events in 27 countries

๐ŸŽ‰ย  ย ๐ŸŽ‰

Blogger

Blogger

Community Leader

900

1.6K

Trainer

Master of Ceremonies

Master of Ceremonies

#cfpwomen

NGRX

  • v6.1.0 (Dec 2015)
  • 132 contributors
  • CLI integration
  • 3.7K stars
  • v3.2 (Jan 2018)
  • 75 contributors
  • Plugins
  • 1.5K stars
  • v4 (June 2015)
  • 622 contributors
  • Inspired by Flux
  • 44K 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, {maxSize: 3,maxAge: 60000});
  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

Thanks

Thanks

Understanding the Selector Pattern and Memoization in NgRx

By Gerard Sans

Understanding the Selector Pattern and Memoization in NgRx

State Management is key to build modern Web Apps

  • 3,696