Reactive state management in Angular with NgRx

Antal Andrei

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

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

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

ngrx/store is a package that we can install with the CLI

ng 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 { createAction, props } from '@ngrx/store';
import { Book } from './model/book';


export const loadBooks = createAction(
  '[Books] Load books'
);

export const loadBooksSuccess = createAction(
  '[Books] Load books success',
  props<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(
      addBook(new Book(title, author))
    );
  } 
}

Data flow

Component

Action

Reducer

State

dispatch

new state

selector

(render)

STORE

sent to

Reducers

import * as BookActions from '../actions';
import { createReducer, on, Action } from '@ngrx/store';

interface BooksState {
  books: Book[];
  loading: boolean;
}

const initialState: BooksState = {
  books: [],
  loading: false,
};

const booksReducer = createReducer(
  initialState,
  on(BookActions.loadBooks, (state) => ({
    ...state,
    loading: true,
  })),
  on(BookActions.addBook, (state, book) => ({
    ...state,
    books: [...state.books, book],
  }))
);

export function reducer(state: BooksState | undefined, action: Action) {
  return booksReducer(state, action);
}

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 { }

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.pipe(select(getReadBooks));
  }
}

Reducers

interface Book {
  id: number;
  title: string;
  author: string;
  read: boolean;
}
interface BookState {
  data: Book[];
  loading: false;
  loaded: false;
}
on(BooksActions.addBook, (state, book) => ({
  ...state,
  data: [...state.data, book]
})
on(BooksActions.removeBook, (state, bookId) => ({
  ...state,
  data: state.data.filter(book => book.id !== bookId)
})

Reducers boilerplate

on(BooksActions.readBook, (state, bookId) => {
  const bookIndex = 
    state.data.findIndex(book => book.id === bookId);

  return {
    ...state,
    data: [
      ...state.data.slice(0, bookIndex),
      {
        ...state.data[bookIndex],
        read: true
      },
      ...state.data.slice(bookIndex+1),
    ]
  };
})
on(BooksActions.readBook, (state, bookId) => {
  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,
});
ng add @ngrx/entity
const booksReducer = createReducer(
  initialState,
  on(BooksActions.addBooks, (state, {books}) => ({
    ...state,
    books: bookAdapter.addAll(books, state.books)
  })),
  on(BooksActions.addBook, (state, book) => ({
    ...state,
    books: adapter.addOne(book, state.books)
  })),
  on(BooksActions.removeBook, (state, book) => ({
    ...state,
    books: bookAdapter.removeOne(book.id, state.books)
  })),
  on(BooksActions.readBook, (state, book) => ({
    ...state,
    books: bookAdapter.updateOne(
      { id: book.id, changes: { read: true } }, 
      state.books
    );
  })),
  on(BooksActions.bookLoaded, (state) => ({
    ...state,
    loaded: true
  })),
);

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, createEffect } from '@ngrx/effects';

import { of } from 'rxjs/observable/of';
import { mergeMap, map, catchError } from 'rxjs/operators';

import * as BookActions from './actions';

@Injectable()
class BooksEffects {
  constructor(private actions$: Actions, private http: Http) {}
 
  addBook$ = createEffect(() =>
    this.actions$.pipe(ofType(BookActions.addBook).pipe(
      mergeMap(book => 
        this.http.post('api/book', book)
          .pipe(
             map(responseBook => BookActions.bookLoadSuccessfuly(responseBook)),
             catchError(error => of(BookActions.bookLoadFail(error)),)
          )
      ))
  );
}

"message bus"

ng add @ngrx/effects

Effects - reducer

export interface BookState {
  books: Book[];
  loading: boolean;
  error: boolean;
}

export const initialState: BookState = {
  books: [],
  loading: false,
  error: undefined,
};


const moviesReducer = createReducer(
  initialState,
  on(BooksActions.addBook, (state) => ({ ...state, loading: true })),
  on(BooksActions.bookAddSuccess, (state, book) => ({
    ...state,
    loading: false,
    error: undefined,
    books: [...state.books, book],
  })),
  on(BooksActions.bookAddFail, (state, error) => ({
    ...state,
    loading: false,
    error,
  }))
);

Un-"reduced" actions

@Injectable()
class BooksEffects {
  constructor(private actions$: Actions, private http: Http) {}
  
  
  
  addBook$ = createEffect(() => 
    this.actions$.ofType(BooksActions.addBook)
      .pipe(
        concatMap(book => this.http.post('api/book', book)
          .pipe(
            map(() => BookActions.bookAddSuccessful(book))
          )
      );
}

const moviesReducer = createReducer(
  initialState,
  on(BookActions.bookAddSuccessful, (state, book) => ({
    ...state,
    books: [...state.books, book],
  }))
)

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) {}
  
  addBook = createEffect(() => 
    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) {}
  
  addBook = createEffect(() => 
    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) {}

  addBook$ = createEffect(() => 
    this.actions$.ofType('ADD_BOOK').pipe(
      map(add => ({
        action: 'ADD_BOOK_BY_USER',
        payload: {...add.payload, user: this.currentUser}
      }))
    )
  );
}
  • Content Enricher

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!

State management with NgRx

By Andrei Antal

State management with NgRx

  • 1,103