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
Advanced State Management using ngrx v6
By Gerard Sans
Advanced State Management using ngrx v6
State Management is key to build modern Web Apps
- 4,794