Angular 6 - Redux con NgRx

¡Importante!

El tutorial no está acabado, falta una parte práctica en github, donde alojo el ejemplo de juego de cartas. Estará para Enero 2019. Gracias. Un saludo ;)

Guía de instalación de redux para un proyecto de Angular 6.

Crearemos de cero la gestión de una baraja de cartas, en este caso de poker

Ejemplo con buenas prácticas

He aquí un proyecto frecuentemente actualizado que toman los creadores de la librería NgRx como referencia. Úsalo como guía de buenas prácticas.

Es una colección de libros que usa api. https://github.com/ngrx/platform

1. Prerequisitos

1.1. Instalación de librerías necesarias

Con esta linea instalas todo lo necesario.

$ npm install -g @angular/cli@latest typescript ionic
$ npm install @ngrx/store @ngrx/effects @ngrx/store @ngrx/store-devtools @ngrx/router-store  --save
$ npm install --save-dev ngrx-store-freeze
$ ng g m redux --module app

Instalación de la app. En este caso con Ionic, aunque puede ser con Angular directamente.

$ ionic start myApp --type=angular

Si usas chrome, al configurar las ngrx/store-devtools, se puede debuguear redux: Ir a chrome dev tools e instalar el plugin: Redux DevTools. Cerrar, abrir, y en modo desarrollo se ve una tab más llamada redux.

Se puede seguir el ciclo completo, paso a paso.

1.2. Paths amigables

Con ello nos libramos de los ../../ que tanto molestan para buscar los archivos a importar en servicios, modulos, componentes. Define paths, en tsconfig.

compilerOptions {
    ....
    "baseUrl": "./src",
    "paths": {
      "@components/*": ["app/components/*"],
      "@pages/*": ["app/pages/*"],
      "@redux/*": ["app/redux/*"],
      "@services/*": ["app/services/*"],
      "@shared/*": ["app/shared/*"],
      "@config/*": ["app/config/*"],
      "@app/env": [
        "environments/environment"
      ]
    }
}

2. Lazy load, modularización y ruteo

Aunque no es necesario, es muy buena práctica que todas nuestras funcionalidades estén completamente separadas. Para nuestra página de poker se necesita un módulo y un componente

ng g m pages/+poker/poker --flat
ng g c pages/+poker/poker

Añadimos en el módulo las rutas

src/app/pages/+poker/poker.module.ts

...
const pokerRoutes = [
  { path: '', pathMatch: 'full', redirectTo: 'poker'},
  { path: 'poker', component: PokerComponent }
];
...
@NgModule({
  imports: [
    ...
    RouterModule.forChild(pokerRoutes)
  ],
    ...
})

Hay que indicarle al routing principal donde está nuestro poker routing

src/app/app-routing.module.ts

...
const routes: Routes = [
  ...
  {
    path: 'poker',
    loadChildren: './pages/+poker/poker.module#PokerModule'
  }

];

@NgModule({
  imports: [
      ... 
      RouterModule.forRoot(routes)
  ],
  ...
})

Entra en http://localhost:4200/poker

3. Redux principal (StoreModule.forRoot)

src/app/redux/poker

Esta será la estructura resultante

 src/app/redux
├── app.reducers.ts
├── poker
│   ├── actions
│   │   ├── index.ts
│   │   └── poker.action.ts
│   ├── data
│   │   └──cardsPoker.json
│   │   
│   ├── effects
│   │   ├── index.ts
│   │   └── poker.effect.ts
│   ├── models
│   └── reducers
│       ├── index.ts
│       └── poker.ts
├── redux.module.spec.ts
├── redux.module.ts
└── shared
    ├── actions
    │   ├── index.ts
    │   └── ui.accions.ts
    └── reducers
        ├── index.ts
        └── ui.reducer.ts

3.1. Módulo redux

Creamos el módulo redux, en el que contendrá todos los reducers, actions, effects, models y json necesarios.

Además contendrá los hijos (lazy load).

$ ng g m redux --module app

src/app/redux/redux.module.ts

import { NgModule } from '@angular/core';


// NgRx
import { StoreModule } from '@ngrx/store';
import { EffectsModule } from '@ngrx/effects';
import { StoreRouterConnectingModule } from '@ngrx/router-store'; // opcional
import { StoreDevtoolsModule } from '@ngrx/store-devtools'; // opcional: instalar plugin chrome.

import { appReducers } from './app.reducers';


// Environment
import { environment } from '@app/env';


@NgModule({
  imports: [
    /**
    * StoreModule.forRoot is imported once in the root module, accepting a reducer
    * function or object map of reducer functions. If passed an object of
    * reducers, combineReducers will be run creating your application
    * meta-reducer. This returns all providers for an @ngrx/store
    * based application.
    */
    StoreModule.forRoot(appReducers),

    /**
  * @ngrx/router-store keeps router state up-to-date in the store.
  */
    StoreRouterConnectingModule.forRoot(),

    /**
     * Store devtools instrument the store retaining past versions of state
     * and recalculating new states. This enables powerful time-travel
     * debugging.
     *
     * To use the debugger, install the Redux Devtools extension for either
     * Chrome or Firefox
     *
     * See: https://github.com/zalmoxisus/redux-devtools-extension
     */
    StoreDevtoolsModule.instrument({
      name: 'NgRx gameCard Store App',
      maxAge: 25, // Retains last 25 states
      logOnly: environment.production, // Restrict extension to log-only mode
    }),


    /**
     * EffectsModule.forRoot() is imported once in the root module and
     * sets up the effects class to be initialized immediately when the
     * application starts.
     *
     * See: https://github.com/ngrx/platform/blob/master/docs/effects/api.md#forroot
     */
    EffectsModule.forRoot([]),
  ],
  declarations: []
})
export class ReduxModule { }

3.2. Reducer y effect Root

Es el reducer que se carga al principio de la app. Sin lazy load.

Dicho reducer y si hubiera effect también, se debería incluir en redux.module.ts, y si no tuviera, tan solo dejar [ ]

...
StoreModule.forRoot(appReducers),
...
EffectsModule.forRoot([]),
...

src/app/redux/app.reducers.ts

import {
    ActionReducerMap,
    createSelector,
    createFeatureSelector,
    ActionReducer,
    MetaReducer,
} from '@ngrx/store';
import { environment } from '@app/env';
import * as fromRouter from '@ngrx/router-store';


/**
* storeFreeze prevents state from being mutated. When mutation occurs, an
* exception will be thrown. This is useful during development mode to
* ensure that none of the reducers accidentally mutates the state.
*/
import { storeFreeze } from 'ngrx-store-freeze';


/**
 * Every reducer module's default export is the reducer function itself. In
 * addition, each module should export a type or interface that describes
 * the state of the reducer plus any selector functions. The `* as`
 * notation packages up all of the exports into a single object.
 */
import * as fromUi from '@redux/shared/reducers/ui.reducer';

/**
 * As mentioned, we treat each reducer like a table in a database. This means
 * our top level state interface is just a map of keys to inner state types.
 */
export interface AppState {
    ui: fromUi.State;
    router: fromRouter.RouterReducerState;
}

/**
 * Our state is composed of a map of action reducer functions.
 * These reducer functions are called with each dispatched action
 * and the current or initial state and return a new immutable state.
 */
export const appReducers: ActionReducerMap<AppState> = {
    ui: fromUi.uiReducer,
    router: fromRouter.routerReducer
};

// console.log all actions
export function logger(reducer: ActionReducer<AppState>): ActionReducer<AppState> {
    return (state: AppState, action: any): any => {
        const result = reducer(state, action);
        console.groupCollapsed(action.type);
        console.log('prev state', state);
        console.log('action', action);
        console.log('next state', result);
        console.groupEnd();

        return result;
    };
}



/**
 * By default, @ngrx/store uses combineReducers with the reducer map to compose
 * the root meta-reducer. To add more meta-reducers, provide an array of meta-reducers
 * that will be composed to form the root meta-reducer.
 */
export const metaReducers: MetaReducer<AppState>[] = !environment.production ? [logger, storeFreeze] : [];



export const getStateUi = createFeatureSelector<fromUi.State>('ui');
export const getStateRouter = createFeatureSelector<AppState>('router');
export const getLoading = createSelector(
    getStateUi
);
export const getRouter = createSelector(
    getStateRouter,
    (state: any) => {
        if (state) {
            return state;
        } else {
            return null;
        }
    }
);

3.3. Action y reducer principales

En este caso, tenemos una carpeta dentro de redux shared en el que contiene unos reducers que están en toda la app, y que no requiere ningún effect.

Una vez creados se incorporan en el app.reducers anteriormente comentado.

Ver github.

4. Redux hijos (StoreModule.forFeature)

A través de lazy load, incorporamos el redux poker al padre, solo y únicamente cuando cargamos cualquier componente asociado al módulo que cargue dicho sub-redux.

4.1. Models

Empezamos con el model. Creamos los modelos necesarios. En este caso uno.

src/app/redux/poker/models/card.model.ts

export interface Card {
    id: string;
    name: string;
    type: string;
}

4.2. Actions

Las acciones son uno de los principales bloques de construcción en NgRx. Las acciones expresan eventos únicos que suceden a lo largo de su aplicación. Desde la interacción del usuario con la página, la interacción externa a través de solicitudes de red y la interacción directa con las API del dispositivo, estos y más eventos se describen con acciones.

Seguimos con las acciones necesarias.

src/app/redux/poker/actions/poker.action.ts

import { Action } from '@ngrx/store';
import { Card } from '../models/card.model';


export enum PokerActionTypes {
    loadPokerCards = '[Poker] Cargar las cartas de Poker',
    loadPokerCardsFail = '[Poker] Cargar las cartas de Poker FAIL',
    loadPokerCardsSuccess = '[Poker] Cargar las cartas de Poker SUCCESS',
}

// carga array poker
export class LoadPokerCards implements Action {
    readonly type = PokerActionTypes.loadPokerCards;
}

export class LoadPokerCardsFail implements Action {
    readonly type = PokerActionTypes.loadPokerCardsFail;
    constructor(public payload: any) { }
}

export class LoadPokerCardsSuccess implements Action {
    readonly type = PokerActionTypes.loadPokerCardsSuccess;
    constructor(public payload: Card[]) { }
}

export type PokerActionsUnion =
    LoadPokerCards |
    LoadPokerCardsFail |
    LoadPokerCardsSuccess
    ;

Puede haber varios archivos de acciones. Este archivo los unifica.

src/app/redux/poker/actions/index.ts

import * as pokerActions from './poker.action';

export {
    pokerActions
};

4.3. Reducers

Los reductores o reducers en NgRx son responsables de manejar las transiciones de un estado al siguiente en su aplicación. Las funciones reducers manejan estas transiciones al determinar qué acciones se manejarán según el tipo.

src/app/redux/poker/reducers/poker.reducer.ts

import { pokerActions } from '../actions';
import { Card } from '../models/card.model';


export interface PokerState {
    pokerList: Card[];
    error?: any;
}

const estadoInicial: PokerState = {
    pokerList: []
};

export function PokerReducer(state = estadoInicial, action: pokerActions.PokerActionsUnion): PokerState {
    switch (action.type) {
        case pokerActions.PokerActionTypes.loadPokerCards:
            return {
                ...state
            };

        case pokerActions.PokerActionTypes.loadPokerCardsSuccess:
            return {
                ...state,
                pokerList: [...action.payload]

            };
        case pokerActions.PokerActionTypes.loadPokerCardsFail:
            return {
                ...state,
                error: {
                    status: action.payload.status,
                    message: action.payload.message,
                    url: action.payload.url
                }
            };

        default:
            return state;
    }
}

Como puede haber varios reducers, se crea un index para que los unifique. Además incorporamos los selectores.

Los selectores son funciones puras utilizadas para obtener segmentos del estado de la store. @ngrx/store proporciona algunas funciones de ayuda. Los selectores proporcionan muchas características al seleccionar segmentos de estado.

src/app/redux/poker/reducers/index.ts

import * as appReducer from '@redux/app.reducers';
import { createSelector, ActionReducerMap, createFeatureSelector } from '@ngrx/store';


// se tiene que exportar así para poder hacer ng build --prod.
import * as fromPoker from './poker.reducer';


export interface PokerState {
    poker: fromPoker.PokerState;
}

export interface State extends appReducer.AppState {
    POKER: PokerState;
}

export const pokerReducers: ActionReducerMap<PokerState> = {
    poker: fromPoker.PokerReducer
};

export const selectPokerState = createFeatureSelector<State, PokerState>('POKER');


export const selectPoker = createSelector(
    selectPokerState,
    (state: PokerState) => state.poker
);

4.4. Effects

Effects proporciona una API para modelar orígenes de eventos como acciones. Efectos: - Escucha las acciones enviadas desde la store. - Aísla los efectos de los componentes, permitiendo componentes más puros que seleccionan acciones de envío y estado. - Proporciona nuevas fuentes de acciones para reducir el estado en función de interacciones externas.

src/app/redux/poker/effects/poker.effect.ts

import { Injectable } from '@angular/core';

import { Action } from '@ngrx/store';
import { Actions, Effect, ofType } from '@ngrx/effects';
import { asyncScheduler, EMPTY as empty, Observable, of } from 'rxjs';
import {
    catchError,
    debounceTime,
    map,
    skip,
    switchMap,
    takeUntil,
    mergeMap,
    flatMap,
} from 'rxjs/operators';

// import { QuestService } from '@services/quest.service';
import { pokerActions } from '../actions';
import { PokerService } from '@services/poker.service';

@Injectable()
export class PokerEffects {
    constructor(
        private actions$: Actions,
        public pokerService: PokerService
    ) {
    }

    @Effect()
    loadQuestList$ = ({ debounce = 300, scheduler = asyncScheduler } = {}): Observable<Action> => this.actions$.pipe(
        ofType<pokerActions.LoadPokerCards>(pokerActions.PokerActionTypes.loadPokerCards),
        debounceTime(debounce, scheduler),
        switchMap(() => {
            return this.pokerService.getPoker().pipe(
                map((data: any) => new pokerActions.LoadPokerCardsSuccess(data)),
                catchError(err => of(new pokerActions.LoadPokerCardsFail(err)))
            );
        })
    )
}

Para unificar todos los effectos, se crea un index.

src/app/redux/poker/effects/index.ts

import { PokerEffects } from './poker.effect';

export const questEffects: any = [PokerEffects];

5. Incorporar Redux en un modulo lazy load

En el módulo donde se quiera cargar, tan solo hay que indicar donde esta el reducer y el effect.

src/app/pages/+poker/poker.module.ts

...
// ngrx
import { StoreModule } from '@ngrx/store';
import { EffectsModule } from '@ngrx/effects';
import { pokerReducers } from '@redux/poker/reducers';
import { PokerEffects } from '@redux/poker/effects/poker.effect';
...

@NgModule({
  imports: [
    ...
    StoreModule.forFeature('POKER', pokerReducers),
    EffectsModule.forFeature([PokerEffects]),
    ...
  ],
  ...
})

6. dispatch (envío) y subscribe (subscriptción a un selector)

src/app/pages/+poker/poker/poker.component.ts

import { Component, OnInit, OnDestroy } from '@angular/core';


import { Store, select } from '@ngrx/store';
import * as fromPokerReducer from '@redux/poker/reducers';
import { pokerActions } from '@redux/poker/actions';
import { Subscription } from 'rxjs'; 


@Component({
  selector: 'app-poker',
  templateUrl: './poker.component.html',
  styleUrls: ['./poker.component.scss']
})
export class PokerComponent implements OnInit, OnDestroy {

  subscription: Subscription;
  constructor(private store: Store<fromPokerReducer.State>) { }

  ngOnInit() {
    this.subscription = this.store.pipe(select(fromPokerReducer.selectPoker)).subscribe(res => {
      if (res && res.pokerList && res.pokerList.length > 0) {
        console.log('poker', res);
      }
    });
    this.store.dispatch(new pokerActions.LoadPokerCards);

  }

  ngOnDestroy() {
    this.subscription.unsubscribe();
  }

}