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
Selector Pattern and Memoisation in ngrx v6
By Gerard Sans
Selector Pattern and Memoisation in ngrx v6
State Management is key to build modern Web Apps
- 3,962