Reactive state management in Angular with NgRx
ngBucharest & BJUG meetup - 27.06.2018 @Netcentric
Antal Andrei
Hello, world!
Andrei Antal
@andrei_antal
- frontend engineer @creative.ai
- web & JS technologies enthusiast
- passion for UI design and UX
- perpetual learner
- occasional speaker at tech events
organizer of ngBucharest
@ngBucharest
groups/angularjs.bucharest
JSLeague
Contents
-
Intro
-
State management in frontend applications
-
Redux
-
NgRx
INTRO
( for the ngUninitiated™ )
Why Angular?
- Well defined practices - but not 100% opinionated
- Streamlined development process - mostly thanks to the CLI
- Rich ecosystem
- Emphasis on standards
The pillars of Angular
- Component driven architecture
- Unidirectional data flow
- Dependency Injection
- Observables as a backbone
- TypeScript
AngularJS 1.x
Module
Config
Routes
$scope
controller
service
view
directive
- The (main) building blocks
Angular
Module
- The (main) building blocks
Routes
Service
Component
Angular - a common pattern
Metadata - decorator
ES6 Class
Angular
Module
- The (main) building blocks
Routes
Service
Component
ES6 Modules
- ES6 modules provide organization at a language level
import { Component, OnInit } from '@angular/core';
import { ItemsService, Item } from '../shared';
export class ItemsComponent implements OnInit {}
Modules export things that other modules can import
@NgModule
- Provides organization at a framework level
@NgModule({
declarations: [
AppComponent,
ItemsComponent,
ItemsListComponent,
ItemDetailComponent
],
imports: [
BrowserModule,
FormsModule,
HttpModule,
Ng2RestAppRoutingModule
],
providers: [ItemsService],
bootstrap: [AppComponent]
})
export class AppModule { }
define view classes that are available to the module - the other view classes in the module
define a list of other modules that the current module needs
define a list of services the module makes available
defines the component that should be bootstrapped
Angular
Module
- The (main) building blocks
Routes
Service
Component
Routes
- A route, in the simplest form, contains a path and a component reference
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { ItemsComponent } from './items/items.component';
const routes: Routes = [
{path: '', redirectTo: '/items', pathMatch: 'full' },
{path: 'items', component: ItemsComponent},
{
path: 'widgets',
loadChildren: '../modules/books/books.module#BooksModule',
},
{path: '**', redirectTo: '/items', pathMatch: 'full'}
];
@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule],
providers: []
})
export class AppRoutingModule { }
Super-easy lazy load of routes
Angular
Module
- The (main) building blocks
Routes
Service
Component
Services/Providers/Injectables
Decorator necessary only if service injects other providers
import { Injectable } from '@angular/core';
import 'rxjs/add/operator/map';
import 'rxjs/add/operator/toPromise';
const BASE_URL = 'http://localhost:3000/items/';
@Injectable()
export class ItemsService {
constructor(private http: Http) {}
loadItems() {
return this.http.get(BASE_URL)
.map(res => res.json())
.toPromise();
}
}
Angular
Module
- The (main) building blocks
Routes
Service
Component
Components
Metadata - the @Component decorator
Class
Template
Bindings
events
properties
Components
- component - "Just a class, with a decorator"
import { Component } from '@angular/core';
@Component({
selector: 'app-component',
template: `
<h1> Hello, {{ name }}! </h1>
<button (click)="changeName()">CHANGE NAME</button>
`,
})
export class AppComponent {
name = 'Andrei';
logGreet() {
this.name = 'Andrew';
console.log(`Name has been changed to ${ this.name }`);
}
}
Angular - a common pattern
Module
Component
Provider
Directive
Pipe
metadata
metadata
metadata
metadata
metadata
class
class
class
class
class
Angular CLI (now in v6)
-
Scaffold a full working project in 3 steps:
- npm insall -g @angular/cli
- ng new [project name]
- ng serve
- Project elements generator (components, service, etc.) that adheres to best practices (naming, structure etc.)
- Schematics for custom flows
STATE MANAGEMENT
State in Frontend Apps
User Interface = function(State)
(Data projection)
const bookTitle = "Alchemy for beginners";
const chaptersTitles = [ "Equipment" ]
let book = {
title: bookTitle,
chapters: chapterTitles
}
.....
Strings, numbers, arrays, objects etc.
Document Object Model
(DOM)
(rendering)
State in Frontend Apps
State (in the broadest sense) is the complex of all of the values held by the application as properties or variables at any given time, and the application is conceived of as acting by executing methods/functions that change the current state to another state.
Some smart developer
State changeing over time
- User input (event callbacks)
- Async requests
- Timers (setInterval, setTimeout)
Application state
-
Data from a remote source
- API server responses
- WebSocket
-
User info
- authentication details
-
User input
- form data
-
UI state
- table pagination
- selected tabs
-
Router / location state
- current URL/route
The need for
state management
- move state management outside of components
- model the application state
- read and update state values
- observe/push state changes
REDUX
In the beginning there was jQuery
KEEPING THE UI IN SYNC WITH THE STATE IS HARD!
Model - View - * to the rescue
Problems with MVC
Unidirectional data flow - Flux
VIEW
STORE
ACTION
DISPATCHER
ACTION
Unidirectional data flow - Flux
FLUX
data flow
REDUX
The 3 main principles:
-
Single source of truth
- one source tree inside a Store
- helps predicatability
-
State is read-only
- dispatch actions to change the state (indirect change)
- immutable update
-
Pure functions update state
-
reducers as pure functions
- respond to actions
- return new state
-
reducers as pure functions
REDUX concepts
- The single state tree
- Actions
- Reducers
- Store
- One way data flow
The single state tree
- One big JavaScript object
- The state object results from reducer composition
// initial state object
const state = {
books: [],
movies: [],
}
Actions
- An action is an object that has 2 properties:
- action type - string, usually the name of the action
- action payload [optional] - data attached to the action
// book addition action object
const action = {
type: 'ADD_BOOK',
payload: {
id: 123,
title: '1984',
author: 'George Orwell',
read: false,
},
};
- Actions are dispatched to reducers
Reducers
- They're just (pure) functions
- Receive the action object as a parameter
- Use the action type and payload to compose and return the new state
- reducers as pure functions are super easy to test
Reducers
// reducer function
function booksReducer(state, action) {
switch(action.type) {
case 'ADD_BOOK':
const newBook = action.payload;
const books = [
...state.books,
newBook
];
return {
...state,
books,
}
...
}
}
new state
Store
- A container for state
- Use it in components:
- subscribe to parts of the store state
- dispatch actions to it
- Takes care of data flow:
- invoke reducers with previous state and action
- composes new state
- notifies subscribers
- invoke reducers with previous state and action
One way data flow
ACTION
STATE
COMPONENT
REDUCER
STORE
dispatches
sent to
returns new
update -> render
One way data flow
NgRX
NgRX
A reactive state management library
- made of Angular
- based on Redux principles
- written with Observables
Main benefits
- better patterns for managing data flow in you application
- tooling (redux dev tools)
- testability
NgRX
yarn add @ngrx/store
Other packages augment the functionalities of the library
Components
Responsibilities of a component
- Initialise it's state
- Reflect state changes according to user actions (clicks)
- Reflect state changes according to app events (timers)
- Make calls to the server to fetch some data
- Reflect state changes when data is fetched
- Receive render updates from other components
- want more? :)
Smart and dumb components
SMART Components
(How things work?)
DUMB Components
(How things look?)
inputs
outputs
Smart and dumb components
Container components
- uses (injects) the Store
- dispatches actions to the store
- reads data from the store
Presentational components
- Not aware of store and does not dispatch actions
- Reads all data it needs from @Inputs
- Signals events to parent via @Outputs
Smart and dumb components
Container Component
Dumb Component
Dumb Component
STORE
@Output
@Output
@Input
@Input
Dispatch
Select
Including ngrx in our project
import { NgModule } from '@angular/core'
import { StoreModule } from '@ngrx/store';
import { booksReducer } from './store/books';
@NgModule({
imports: [
BrowserModule,
StoreModule.forRoot({ books: booksReducer })
]
})
export class AppModule {}
- only used once, for the root module
- for a feature or lazily loaded modules, we should use the forFeature static method
Dispatch actions
import { Component } from '@angular/core';
import { Store } from '@ngrx/store';
import { AddBook } from './store.actions';
import { Book } from './model/book';
@Component({
selector: 'books',
templateUrl: './books.component.html'
})
class BooksComponent {
constructor(private store: Store<any>) {}
addBook(title: string, author: string) {
this.store.dispatch({
type: 'ADD_BOOK',
payload: new Book(title, author))
});
}
}
Actions
import { Action } from '@ngrx/store';
import { Book } from './model/book';
export enum BookActionTypes {
LOAD_BOOKS = '[Books] Load books',
ADD_BOOK = '[Books] Add one book'
}
export class LoadBooks implements Action {
readonly type = BookActionTypes.LOAD_BOOKS;
}
export class AddBook implements Action {
readonly type = BookActionTypes.ADD_BOOK;
constructor(public payload: Book) {}
}
Action hygiene - best practices
'[Source] Event'
-
Don't reuse actions
- actions should capture events not commands
-
Explicit action names
- understand how the app works
- should be descriptive and specific
-
Don't subtype actions
- avoid nested conditionals
- Should cause state changes and/or trigger side effects
Dispatch actions
import { Component } from '@angular/core';
import { Store } from '@ngrx/store';
import { AddBook } from './store.actions';
import { Book } from './model/book';
@Component({
selector: 'books',
templateUrl: './books.component.html'
})
class BooksComponent {
constructor(private store: Store<any>) {}
addBook(title: string, author: string) {
this.store.dispatch(
new AddBook(new Book(title, author))
);
}
}
Data flow
Component
Action
Reducer
State
dispatch
new state
selector
(render)
STORE
sent to
Reducers
import { LOAD_BOOKS, ADD_BOOK } from '../actions';
interface BooksState {
books: Book[];
loading: boolean;
}
const initialState: BooksState = {
books: [],
loading: false,
};
export function reducer(state = initialState, action): BooksState {
switch (action.type) {
case LOAD_BOOKS: {
return {
...state,
loading: true,
};
}
case ADD_BOOK: {
return {
...state,
books: [...state.books, action.payload],
};
}
}
return state;
}
Using reducers
import { NgModule } from '@angular/core';
import { StoreModule } from '@ngrx/store';
import { reducer } from './store/books.reducer';
@NgModule({
declarations: [],
imports: [StoreModule.forFeature('books', reducer)]
})
export default class BooksModule { }
...
import BooksModule from './books/books.module';
@NgModule({
imports: [ BrowserModule, StoreModule.forRoot({}), BooksModule ],
...
})
export class AppModule { }
Getting data from the store
@Component({
selector: 'books',
template: `
<div class="book" *ngFor="let book of books">
<span>{{book.title}}</span> - <span>{{book.author}}</span>
</div>
`
})
class BooksComponent {
public books: Book[];
constructor(private store: Store<any>) { }
ngOnInit() {
this.store
.select('books')
.subscribe(data => this.books = data.books)
}
}
interface BooksState {
books: Book[];
loading: boolean;
}
interface Book {
title: string;
author: string;
read: boolean;
}
Getting data from the store
@Component({
selector: 'books',
template: `
<div class="book" *ngFor="let book of books">
<span>{{book.title}}</span> - <span>{{book.author}}</span>
</div>
`
})
class BooksComponent {
public books: Book[];
constructor(private store: Store<any>) { }
ngOnInit() {
this.store
.select('books')
.subscribe(data =>
this.books = data.books.filter(book => !book.read)
)
}
}
Selectors - queries for the store
import { createFeatureSelector, createSelector } from '@ngrx/store';
export const getBooksState = createFeatureSelector<BookState>('books');
export const getAllBooks =
createSelector(
getBooksState,
(booksState: BookState) => booksState.data
);
export const getReadBooks =
createSelector(
getAllBooks,
(books: Book[]) => books.filter(book => book.read === true)
);
{
data: [
{ author: 'Author 1', name: 'Book 1', read: true },
{ author: 'Author 2', name: 'Book 2', read: false },
{ author: 'Author 3', name: 'Book 3', read: false }
],
loading: false
}
[
{ author: 'Author 1', name: 'Book 1', read: true },
{ author: 'Author 2', name: 'Book 2', read: false },
{ author: 'Author 3', name: 'Book 3', read: false }
]
[{ author: 'Author 1', name: 'Book 1', read: true }]
Selectors
import { BooksState, getReadBooks } from './books.reducers';
@Component({
selector: 'books',
template: `
<div class="book" *ngFor="let book of books$ | async">
<span>{{book.title}}</span> - <span>{{book.author}}</span>
</div>
`
})
class BooksComponent {
public books$: Observable<Book[]>;
constructor(private store: Store<BooksState>) { }
ngOnInit() {
this.books$ = this.store.select(getReadBooks)
}
}
Reducers
interface Book {
id: number;
title: string;
author: string;
read: boolean;
}
interface BookState {
data: Book[];
loading: false;
loaded: false;
}
case BookActions.ADD_BOOK: {
return {
...state,
data: [...state.data, action.payload]
}
}
case BookActions.REMOVE_BOOK: {
return {
...state,
data: state.data.filter(book => book.id !== action.payload)
}
}
Reducers boilerplate
case BookActions.READ_BOOK: {
const bookIndex =
state.data.findIndex(book => book.id === action.payload);
return {
...state,
data: [
...state.data.slice(0, bookIndex),
{
...state.data[bookIndex],
read: true
},
...state.data.slice(bookIndex+1),
]
};
}
case BookActions.READ_BOOK: {
const bookIndex =
state.data.findIndex(book => book.id === action.payload);
return {
...state,
data:
state.data.slice(0, bookIndex)
.concat({...state.data[bookIndex], read: true})
.concat(state.data.slice(bookIndex+1))
};
}
"Reducing" the boilerplate
[
{id: 1, ...},
{id: 2, ...},
{id: 3, ...}
]
{
1: {id: 1, ...},
2: {id: 2, ...},
3: {id: 3, ...}
}
Entities
import { createEntityAdapter } from '@ngrx/entity';
const bookAdapter = createEntityAdapter<Book>();
import { EntityState } from '@ngrx/entity';
export interface BookState extends EntityState<Book> {
loading: boolean,
}
const initialState: BookState = bookAdapter.getInitialState({
loading: true,
});
yarn add @ngrx/entity
function reducer( state = initialState, action ): BookState {
switch (action.type) {
case BookActions.ADD_BOOKS: {
return bookAdapter.addAll(action.payload.books, state);
}
case BookActions.ADD_BOOK: {
return bookAdapter.addOne(action.payload.book, state);
}
case BookActions.REMOVE_BOOK: {
return bookAdapter.removeOne(action.payload.id, state);
}
case BookActions.READ_BOOK: {
return bookAdapter.updateOne(
{ id: action.payload.id, changes: { read: true } }, state
);
}
case BookAction.BOOK_LOADED: {
return { ...state, loaded: true };
}
}
return state;
}
Data flow
Component
Action
Reducer
State
dispatch
new state
render
STORE
sent to
CQS
Command Query Separation
Dispatcher
State
Commands
Queries
Actions
Selectors
UI
Action types
Action types
export class AddBook implements Action {
type = BookActions.ADD_BOOK;
constructor(public payload: Book) {}
}
- Commands
export class SetBooks implements Action {
type = BookActions.SET_BOOKS;
constructor(public payload: Book[]) {}
}
- Documents
export class BooksLoadedSuccess implements Action {
type = BooksActions.FETCH_SUCCESS;
constructor(public payload: any) {}
}
- Events
Action types
LOAD_BOOKS
LOADER_VISIBLE
FETCH_BOOKS
FETCH_SUCCESS
SET_BOOKS
SET_ERROR
FETCH_ERROR
LOADER_HIDDEN
LOADER_HIDDEN
Action types
LOAD_BOOKS
LOADER_VISIBLE
FETCH_BOOKS
FETCH_SUCCESS
SET_BOOKS
SET_ERROR
FETCH_ERROR
LOADER_HIDDEN
LOADER_HIDDEN
(command)
(command)
(document)
(document)
(document)
(document)
(document)
(event)
(event)
Data flow
Component
Action
Reducer
State
dispatch
new state
render
STORE
sent to
Data flow - with side effects
Component
Action
Reducer
State
dispatch
new state
render
STORE
sent to
EFFECTS
EFFECT
SERVICE
Action
fetch
receive
dispatch
Effects
import { Injectable } from '@angular/core';
import { Actions, Effect } from '@ngrx/effects';
import { of } from 'rxjs/observable/of';
import { switchMap, map, catchError } from 'rxjs/operators';
@Injectable()
class BooksEffects {
constructor(private actions$: Actions, private http: Http) {}
@Effect()
addBook$ = this.actions$.ofType('ADD_BOOK').pipe(
switchMap(book =>
this.http.post('api/book', book)
.pipe(
map(responseBook =>
({type: 'BOOK_ADDED', payload: responseBook}),
catchError(error =>
of({type: 'BOOK_ADD_FAIL', payload: error}))
)
)
);
}
"message bus"
yarn add @ngrx/effects
Effects - reducer
export interface BookState {
books: Book[];
loading: boolean;
error: boolean;
}
export const initialState: BookState = {
books: [],
loading: false,
error: undefined,
};
export function reducer(state = initialState, action): BookState {
switch (action.type) {
case ADD_BOOK: {
return { ...state, error: undefined, loading: true };
}
case BOOK_ADDED: {
return { ...state, loading: false, books: [...state.books, action.payload] };
}
case BOOK_ADD_FAIL: {
return { ...state, error: action.payload, loading: false };
}
}
return state;
}
Un-"reduced" actions
@Injectable()
class BooksEffects {
constructor(private actions$: Actions, private http: Http) {}
@Effect()
addBook$ = this.actions$.ofType('ADD_BOOK')
.pipe(
concatMap(book => this.http.post('api/book', book)
.pipe(
map(() => ({type: 'BOOK_ADDED', payload: book}))
)
);
}
function booksReducer(state: Book[] = [], action: any): Book[] {
switch (action.type) {
case('BOOK_ADDED') {
return [...books, action.payload];
}
}
return todos;
}
This action is not handled in the reducer, it's dispatched just for the effect
Adding effects to the store
...
import { StoreModule } from '@ngrx/store';
import { EffectsModule } from '@ngrx/effects';
import { reducer } from './store/books.reducer';
import BooksEffects from './store/books.effects';
@NgModule({
...
imports: [
...
StoreModule.forFeature('books', reducer),
EffectsModule.forFeature([BooksEffects])
],
providers: [BooksEffects]
})
export default class BooksModule {}
Effect service must also be declared in the providers array
Store
Credits to:
Effect classes
Effect classes
Effect deciders examples
class BooksEffects {
constructor(private actions: Actions, private env: Env) {}
@Effect()
addBook = this.actions.typeOf('ADD_BOOK').pipe(map(addBook => {
if (this.env.confirmationIsOn) {
return ({type: 'ADD_BOOK_WITH_CONFIRMATION', payload: addBook.payload});
} else {
return ({type: 'ADD_BOOK_WITHOUT_CONFIRMATION', payload: addBook.payload});
}
});
}
- Context-Based Decider
class BooksEffects {
constructor(private actions: Actions) {}
@Effect()
addBook = this.actions.typeOf('REQUEST_ADD_BOOK').pipe(flatMap(add => [
{type: 'ADD_BOOK', payload: add.payload},
{type: 'LOG_OPERATION', payload: {loggedAction: 'ADD_BOOK', payload: add.payload}}
]);
}
- Splitter
Action Transformers example
class BooksEffects {
constructor(private actions$: Actions, private currentUser: User) {}
@Effect()
addBook$ = this.actions$.ofType('ADD_BOOK').pipe(
map(add => ({
action: 'ADD_BOOK_BY_USER',
payload: {...add.payload, user: this.currentUser}
}))
);
}
- Content Enricher
Redux devtools
DEMO
Conclusion
NgRx main benefits
- patterns for developing your application
- predictable centralised reactive state
- awesome tooling
- testability
NgRx - things to mind
- state architecture (what should be in the state)
- boilerplate
- unneeded complexity
ngrx stuff not covered
- reducing boilerplate with @ngrx/data
- @ngrx/router
- state preload with route guards
- testing
Thank you!
Build awesome shit!
ngBucharest meetup - State management with NgRx
By Andrei Antal
ngBucharest meetup - State management with NgRx
ngBucharest & BJUG meetup @netcentric. 27.06.2018
- 1,153