NgRX: A Reactive State for Angular

@adrian.afergon

Who Am I?

  • Full Stack Developer at Lean Mind
  • Passionate about development
  • Typescript lover ❤️

Adrián Ferrera González

@adrian.afergon

@afergon

adrian-afergon

@adrian.afergon

What is the reactivity?

@adrian.afergon

What does it give me?

@adrian.afergon

  • Action - Reaction
  • Single source of truth
  • Understable workflow
  • Simple datahandlers
  • Readeable asynchronous

What should I know?

State

Actions

Reducers

Selectors

Streams

Example !== Reality

@adrian.afergon

Container

vs

Presentations

@adrian.afergon

NgRX

@adrian.afergon

Brandon Roberts

Mike Ryan

What it is?

@adrian.afergon

@adrian.afergon

What makes it up

  • @ngrx/store
  • @ngrx/effects
  • @ngrx/entity
  • @ngrx/router-store
  • @ngrx/store-devtools

@adrian.afergon

Synonims

Technology redux-pattern Side-effects
React Redux Redux-observables
Angular NgRx Effects
Vue Vuex Vue-RX

@adrian.afergon

Installation

ng add @ngrx/store

ng add @ngrx/effects

ng add @ngrx/store-devtools

@adrian.afergon

Configure your module

@NgModule({
  imports: [
    CommonModule,
    RouterModule,
    SharedModule,
    HttpClientModule,
    CoreRouter,
    StoreModule.forRoot(reducers),
    EffectsModule.forRoot([AppEffects]),
    StoreDevtoolsModule.instrument({ maxAge: 25, logOnly: environment.production })
  ],
  declarations: [HeaderComponent, MainComponent, FooterComponent],
  exports: [MainComponent]
})
export class CoreModule { }

@adrian.afergon

Define your state

export interface GauntletState {
  powerStone: Stone;
  spaceStone: Stone;
  realityStone: Stone;
  mindStone: Stone;
  soulStone: Stone;
  timeStone: Stone;
}

For your Store / GlobalState

@adrian.afergon

Implements your actions

export const EQUIP = '[Gauntlet] equip';

export class Equip implements Action {
  readonly type = EQUIP;
  constructor(public stoneId: string) {}
}

export type GauntletActions
  = Equip;

@adrian.afergon

Define a reducer

export function gauntletReducer(state: GauntletState = defaultState, action: GauntletActions) {
  switch (action.type) {
    case GET_STONES_FULFILLED:
      return {...state, ...action.gauntlet};
    case EQUIP:
      const stone = state[action.stoneId];
      return {...state, [action.stoneId]: {...stone, equipped: !stone.equipped} };
    default:
      return state;
  }
}

@adrian.afergon

Connect and use your Containers

@Component({
  selector: 'app-gauntlet',
  templateUrl: './gauntlet.component.html',
  styleUrls: ['./gauntlet.component.scss']
})
export class GauntletComponent implements OnInit {
  public stones$: Observable<Array<StoneModel>>;
  public isGauntletCompleted$: Observable<boolean>;
  constructor(private store: Store<GauntletState>) {
    this.stones$ = store.pipe(select(GauntletSelectors.getGauntletAsViewModel));
    this.isGauntletCompleted$ = store.pipe(select(GauntletSelectors.isGauntletCompleted));
  }

  ngOnInit() {
    this.store.dispatch(new GetStones());
  }

  public onSnap() {
    this.store.dispatch(new Snap());
  }
  public equipStone(stoneId) {
    this.store.dispatch(new Equip(stoneId));
  }
}

@adrian.afergon

Optimize with selectors

export class GauntletSelectors {
  static getGauntletState = createFeatureSelector<GauntletState>('gauntlet');
  static getGauntletAsViewModel = createSelector(GauntletSelectors.getGauntletState,
    (state): StoneModel[] => GauntletMapper.toViewModel(state));
  static isGauntletCompleted = createSelector(GauntletSelectors.getGauntletAsViewModel,
      stones => !!stones.find(stone => !stone.equipped));
}

@adrian.afergon

Handle your side effects

@Injectable()
export class AppEffects {
  constructor(
    private actions$: Actions, 
    private stoneRepository: StonesRepository, 
    private socketClient: SocketClient) {}

  @Effect()
  getStones: Observable<Action> = this.actions$.pipe(
    ofType(GET_STONES),
    switchMap(action => this.stoneRepository.getStones()),
    map((gauntlet: Gauntlet) => new GetStonesFulfilled(gauntlet)),
    catchError( error => of(new GetStonesRejected(error)))
  );
  @Effect({dispatch: false})
  onSnap = this.actions$.pipe(
    ofType(SNAP),
    tap( () => this.socketClient.snap())
  );
}

@adrian.afergon

Questions?

@adrian.afergon

Step 1

@adrian.afergon

$ git clone https://github.com/adrian-afergon/jsDayCan18-infinite-wars.git
$ cd jsDayCan18-infinite-wars
$ npm install
$ ng serve
$ git checkout step-1

Clone and install

Chrome redux dev-tools

Configure module

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';

import { AppComponent } from './app.component';
import { StoreModule } from '@ngrx/store';
import { reducers } from './reducers';
import { EffectsModule } from '@ngrx/effects';
import { AppEffects } from './app.effects';
import { CoreModule } from './core/core.module';
import { StoreDevtoolsModule } from '@ngrx/store-devtools';
import { environment } from '../environments/environment';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule,
    CoreModule,
    StoreModule.forRoot(reducers),
    EffectsModule.forRoot([AppEffects]),
    StoreDevtoolsModule.instrument({ maxAge: 25, logOnly: environment.production })

  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

@adrian.afergon

Step 2

$ git checkout step-2

Define Gauntlet State

import { ActionReducerMap } from '@ngrx/store';
import {Stone} from '../core/model/stones';

interface GauntletState {
  powerStone: Stone;
  spaceStone: Stone;
  realityStone: Stone;
  mindStone: Stone;
  soulStone: Stone;
  timeStone: Stone;
}

export interface State {
  gauntlet: GauntletState;
}

export const reducers: ActionReducerMap<State> = {

};

@adrian.afergon

Step 3

$ git checkout step-3

Return initial state hacked

//reducers/gauntlet.reducer.ts

import {Action} from '@ngrx/store';
import {Stone} from '../core/model/stones';
import {gauntlet} from './gauntlet.hack';

export interface GauntletState {
  powerStone: Stone;
  spaceStone: Stone;
  realityStone: Stone;
  mindStone: Stone;
  soulStone: Stone;
  timeStone: Stone;
}

export function gauntletReducer(state: GauntletState = gauntlet, action: Action) {
  switch (action.type) {
    default:
      return state;
  }
}

@adrian.afergon

Step 3

Map reducer

//reducers/index.ts

import {ActionReducerMap} from '@ngrx/store';
import {gauntletReducer, GauntletState} from './gauntlet.reducer';

export interface State {
  gauntlet: GauntletState;
}

export const reducers: ActionReducerMap<State> = {
  gauntlet: gauntletReducer
};

@adrian.afergon

Step 4

$ git checkout step-4

Display values

// container/gauntlet.component.ts

import {Component, OnDestroy, OnInit} from '@angular/core';
import { SocketClient } from '../../../core/services/socket.client';
import { StonesRepository } from '../../../core/services/stones.repository';
import { StoneModel } from '../../viewmodel/Stone.model';
import { GauntletMapper } from '../../mappers/gauntlet.mapper';
import {Observable} from 'rxjs';
import {select, Store} from '@ngrx/store';
import {GauntletState} from '../../../reducers/gauntlet.reducer';

@Component({
  selector: 'app-gauntlet',
  templateUrl: './gauntlet.component.html',
  styleUrls: ['./gauntlet.component.scss']
})
export class GauntletComponent implements OnInit, OnDestroy {
  public stones: StoneModel[];
  public stones$: Observable<GauntletState>;
  // public subscription: Subscription;
  constructor(
    private store: Store<GauntletState>, 
    private socketClient: SocketClient, 
    private stonesRepository: StonesRepository) {
    this.stones = [];
    this.stones$ = store.pipe(select('gauntlet'));
  }

  ngOnInit() {
    // this.subscription = this.stonesRepository.getStones()
    // .subscribe(gauntlet => this.stones = GauntletMapper.toViewModel(gauntlet));
  }
  ngOnDestroy() {
    // this.subscription.unsubscribe();
}

  public isGauntletCompleted() {
    return !!this.stones.find(stone => !stone.equipped);
  }
  public onSnap() {
    this.socketClient.snap();
  }
  public equipStone(stoneId) {
    this.stones.map( stone => stone.id === stoneId ? stone.equipped = !stone.equipped : stone);
  }
  public toViewModel(data) {
    console.log('re-render...');
    return GauntletMapper.toViewModel(data);
  }
}

@adrian.afergon

Step 4

Display values

<!-- container/gauntlet.component.html -->

<div id="gauntlet">
  <div class="gauntlet-content">
    <app-infinity-gauntlet [disabled]="isGauntletCompleted()" 
        (onSnap)="onSnap()"></app-infinity-gauntlet>
  </div>
  <div class="stone-list" *ngIf="stones$ | async as stones">
    <app-stone *ngFor="let stone of toViewModel(stones)" 
        [stone]="stone" 
        (onClick)="equipStone($event)" ></app-stone>
  </div>
</div>

@adrian.afergon

Step 4

Problems...

  • Re-rendering
  • Actions
  • Validations

@adrian.afergon

Step 5

$ git checkout step-5

Use selectors for map the state to view-model

// reducers/gauntlet.reducer.ts

const getGauntletState = createFeatureSelector<GauntletState>('gauntlet');
export const getGauntletAsViewModel = 
    createSelector(getGauntletState, (state): StoneModel[] => GauntletMapper.toViewModel(state));

Change Component

// container/gauntlet.component.ts

// [...]

export class GauntletComponent implements OnInit {
  public stones: StoneModel[];
  public stones$: Observable<any>;
  constructor(private store: Store<GauntletState>, private socketClient: SocketClient, private stonesRepository: StonesRepository) {
    this.stones = [];
    this.stones$ = store.pipe(select(getGauntletAsViewModel));
    store.pipe(select(getGauntletAsViewModel)).subscribe(value => {
      console.log('re-render');
    });
  }
// [...]
}

@adrian.afergon

Step 6

$ git checkout step-6

Refactor selectors

// selectors/gauntlet.selectors.ts

import {createFeatureSelector, createSelector} from '@ngrx/store';
import {StoneModel} from '../gauntlet/viewmodel/Stone.model';
import {GauntletMapper} from '../gauntlet/mappers/gauntlet.mapper';
import {GauntletState} from '../reducers/gauntlet.reducer';

export class GauntletSelectors {
  static getGauntletState = createFeatureSelector<GauntletState>('gauntlet');
  static getGauntletAsViewModel = createSelector(GauntletSelectors.getGauntletState,
    (state): StoneModel[] => GauntletMapper.toViewModel(state));
  static isGauntletCompleted = createSelector(GauntletSelectors.getGauntletAsViewModel,
      stones => !!stones.find(stone => !stone.equipped));
}

@adrian.afergon

Step 6

Change the container

import {Component, OnInit} from '@angular/core';
import { SocketClient } from '../../../core/services/socket.client';
import { StonesRepository } from '../../../core/services/stones.repository';
import { StoneModel } from '../../viewmodel/Stone.model';
import {Observable} from 'rxjs';
import {select, Store} from '@ngrx/store';
import {GauntletState} from '../../../reducers/gauntlet.reducer';
import {GauntletSelectors} from '../../../selectors/gauntlet.selectors';

@Component({
  selector: 'app-gauntlet',
  templateUrl: './gauntlet.component.html',
  styleUrls: ['./gauntlet.component.scss']
})
export class GauntletComponent implements OnInit {
  public stones: StoneModel[];
  public stones$: Observable<Array<StoneModel>>;
  public isGauntletCompleted$: Observable<boolean>;
  constructor(
    private store: Store<GauntletState>, 
    private socketClient: SocketClient, 
    private stonesRepository: StonesRepository) {
    this.stones = [];
    this.stones$ = store.pipe(select(GauntletSelectors.getGauntletAsViewModel));
    this.isGauntletCompleted$ = store.pipe(select(GauntletSelectors.isGauntletCompleted));
  }

  ngOnInit() {
    // this.subscription = this.stonesRepository.getStones()
    // .subscribe(gauntlet => this.stones = GauntletMapper.toViewModel(gauntlet));
  }

  public onSnap() {
    this.socketClient.snap();
  }
  public equipStone(stoneId) {
    this.stones.map( stone => stone.id === stoneId ? stone.equipped = !stone.equipped : stone);
  }
}

@adrian.afergon

Step 6

And the view

<div id="gauntlet">
  <div class="gauntlet-content">
    <app-infinity-gauntlet 
        [disabled]="isGauntletCompleted$ | async" 
        (onSnap)="onSnap()"></app-infinity-gauntlet>
  </div>
  <div class="stone-list" *ngIf="stones$ | async as stones">
    <app-stone *ngFor="let stone of stones" 
        [stone]="stone" 
        (onClick)="equipStone($event)" ></app-stone>
  </div>
</div>

@adrian.afergon

Step 7

$ git checkout step-7

Define actions for mutate out state

export const EQUIP = '[Gauntlet] equip';
export const SNAP = '[Gauntlet] snap';

(...)

@adrian.afergon

(...)

@adrian.afergon

Step 7

Typescript the rescue

import {Action} from '@ngrx/store';

export const EQUIP = '[Gauntlet] equip';
export const SNAP = '[Gauntlet] snap';

export class Equip implements Action {
  readonly type = EQUIP;
  constructor(public stoneId: string) {}
}

export class Snap implements Action {
  readonly  type = SNAP;
  constructor() {}
}

export type GauntletActions
  = Equip
  | Snap;

@adrian.afergon

Step 7

import {Stone} from '../core/model/stones';
import {gauntlet} from './gauntlet.hack';
import {EQUIP, GauntletActions} from '../actions/gauntlet.actions';

export interface GauntletState {
  powerStone: Stone;
  spaceStone: Stone;
  realityStone: Stone;
  mindStone: Stone;
  soulStone: Stone;
  timeStone: Stone;
}

export function gauntletReducer(state: GauntletState = gauntlet, action: GauntletActions) {
  switch (action.type) {
    case EQUIP:
      const stone = state[action.stoneId];
      return {...state, [action.stoneId]: {...stone, equipped: !stone.equipped} };
    default:
      return state;
  }
}

@adrian.afergon

@adrian.afergon

Step 7

Now the container

// [...]
import {Equip} from '../../../actions/gauntlet.actions';

@Component({
  selector: 'app-gauntlet',
  templateUrl: './gauntlet.component.html',
  styleUrls: ['./gauntlet.component.scss']
})
export class GauntletComponent implements OnInit {
  public stones$: Observable<Array<StoneModel>>;
  public isGauntletCompleted$: Observable<boolean>;
// [...]

 public equipStone(stoneId) {
    this.store.dispatch(new Equip(stoneId));
  }
}

@adrian.afergon

Step 8

$ git checkout step-8

Effects

PART - 1

Create the actions

import {Action} from '@ngrx/store';
import {Gauntlet} from '../core/model/stones';

export const EQUIP = '[Gauntlet] equip';

export const GET_STONES = '[Gauntlet] get stones';
export const GET_STONES_FULFILLED = '[Gauntlet] get stones fulfilled';
export const GET_STONES_REJECTED = '[Gauntlet] get stones rejected';


export class GetStones implements Action {
  readonly type = GET_STONES;
  constructor() {}
}

export class GetStonesFulfilled implements Action {
  readonly type = GET_STONES_FULFILLED;
  constructor(public gauntlet: Gauntlet) {}
}

export class GetStonesRejected implements Action {
  readonly type = GET_STONES_REJECTED;
  constructor(public error: string) {}
}

export class Snap implements Action {
  readonly  type = SNAP;
  constructor() {}
}

export type GauntletActions
  = Equip
  | GetStones
  | GetStonesFulfilled
  | GetStonesRejected
  | Snap;

@adrian.afergon

Step 9

$ git checkout step-9

Effects

PART - 2

Create the effect

@Injectable()
export class AppEffects {
  constructor(
    private actions$: Actions, 
    private stoneRepository: StonesRepository, 
    private socketClient: SocketClient) {}

  @Effect()
  getStones: Observable<Action> = this.actions$.pipe(
    ofType(GET_STONES),
    switchMap(action => this.stoneRepository.getStones()),
    map((gauntlet: Gauntlet) => new GetStonesFulfilled(gauntlet)),
    catchError( error => of(new GetStonesRejected(error)))
  );
}

@adrian.afergon

Step 10

$ git checkout step-10

Effects

PART - 3

Create the effect for snap

@Injectable()
export class AppEffects {
  constructor(
    private actions$: Actions, 
    private stoneRepository: StonesRepository, 
    private socketClient: SocketClient) {}

  // [...]
  @Effect({dispatch: false})
  onSnap = this.actions$.pipe(
    ofType(SNAP),
    tap( () => this.socketClient.snap())
  );
}

@adrian.afergon

Step 11

$ git checkout step-11

Create feature reducer

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { PopulationComponent } from './containers/population/population.component';
import {PopulationRouter} from './population.routing';
import {SharedModule} from '../shared/shared.module';
import {StoreModule} from '@ngrx/store';
import {EffectsModule} from '@ngrx/effects';
import {populationReducer} from './infrastructure/reducers/';

@NgModule({
  imports: [
    CommonModule,
    SharedModule,
    PopulationRouter,
    StoreModule.forFeature('population', populationReducer),
    EffectsModule.forFeature([])
  ],
  declarations: [PopulationComponent]
})
export class PopulationModule { }

@adrian.afergon

Step 11

Create feature reducer

import {Action} from '@ngrx/store';

interface PopulationState {
  people: number;
}

const defaultState: PopulationState = {
  people: 0
};

export function populationReducer(state: PopulationState = defaultState, action: Action) {
  switch (action.type) {
    default:
      return state;
  }
}

@adrian.afergon

Step 12

Complete the population container using the knwoledge lerned

$ git checkout step-12
$ git checkout result

@adrian.afergon

Interesting links

LeanMind Medium profile:

https://medium.com/lean-mind

And my Medium profile:

https://medium.com/@Afergon 🙄

@adrian.afergon

Questions?

@adrian.afergon

Thank you a lot!!

@adrian.afergon

JsDayCan18

By afergon

JsDayCan18

NgRX: A reactive state for Angular.

  • 876