Getting started with Angular and Redux

This article shows how you could setup Redux in an Angular application using ngrx. Redux provides a really great way of managing state in an Angular application. State Management is hard, and usually ends up a mess when you invent it yourself. At present, Angular provides no recommendations or solution for this.

Thanks to Fabian Gosebrink for his help in learning ngrx and Redux. The to Philip Steinebrunner for his feedback.

Code: https://github.com/damienbod/AngularRedux

History

2017-11-05 Updated to Angular 5 and Typescript 2.6.1

The demo app uses an Angular component for displaying countries using the public API https://restcountries.eu/. The view displays regions and the countries per region. The data and the state of the component is implemented using ngrx.

Note: Read the Redux documentation to learn how it works. Here’s a quick summary of the redux store in this application:

  • There is just one store per application while you can register additional reducers for your Feature-Modules with StoreModule.forFeature() per module
  • The store has a state, actions, effects, and reducers
  • The actions define what can be done in the store. Components or effects dispatch these
  • effects are use to do API calls, etc and are attached to actions
  • reducers are attached to actions and are used to change the state

The following steps explains, what is required to get the state management setup in the Angular application, which uses an Angular service to request the data from the public API.

Step 1: Add the ngrx packages

Add the latest ngrx npm packages to the packages.json file in your project.

    "@ngrx/effects": "^4.1.0",
    "@ngrx/store": "^4.1.0",
    "@ngrx/store-devtools": "^4.0.0",

Step 2: Add the ngrx setup configuration to the app module.

In this app, a single Redux store will be used per module. The ngrx configuration needs to be added to the app.module and also each child module as required. The StoreModule, EffectsModule and the StoreDevtoolsModule are added to the imports array of the NgModule.

...

import { EffectsModule } from '@ngrx/effects';
import { StoreModule } from '@ngrx/store';
import { StoreDevtoolsModule } from '@ngrx/store-devtools';

@NgModule({
    imports: [
        ...
        StoreModule.forRoot({}),
        StoreDevtoolsModule.instrument({
            maxAge: 25 //  Retains last 25 states
        }),
        EffectsModule.forRoot([])
    ],

    declarations: [
        AppComponent
    ],

    bootstrap: [AppComponent],
})

export class AppModule { }

Step 3: Create the interface for the state.

This can be any type of object, array.

import { Region } from './../../models/region';

export interface CountryState {
    regions: Region[],
};

Step 4: Create the actions

Create the actions required by the components or the effects. The constructor params must match the params sent from the components or returned from the API calls.

import { Action } from '@ngrx/store';
import { Country } from './../../models/country';
import { Region } from './../../models/region';

export const SELECTALL = '[countries] Select All';
export const SELECTALL_COMPLETE = '[countries] Select All Complete';
export const SELECTREGION = '[countries] Select Region';
export const SELECTREGION_COMPLETE = '[countries] Select Region Complete';

export const COLLAPSEREGION = '[countries] COLLAPSE Region';

export class SelectAllAction implements Action {
    readonly type = SELECTALL;

    constructor() { }
}

export class SelectAllCompleteAction implements Action {
    readonly type = SELECTALL_COMPLETE;

    constructor(public countries: Country[]) { }
}

export class SelectRegionAction implements Action {
    readonly type = SELECTREGION;

    constructor(public region: Region) { }
}

export class SelectRegionCompleteAction implements Action {
    readonly type = SELECTREGION_COMPLETE;

    constructor(public region: Region) { }
}

export class CollapseRegionAction implements Action {
    readonly type = COLLAPSEREGION;

    constructor(public region: Region) { }
}

export type Actions
    = SelectAllAction
    | SelectAllCompleteAction
    | SelectRegionAction
    | SelectRegionCompleteAction
    | CollapseRegionAction;


Step 5: Create the effects

Create the effects to do the API calls. The effects are mapped to actions and when finished call another action.

import 'rxjs/add/operator/map';
import 'rxjs/add/operator/switchMap';

import { Injectable } from '@angular/core';
import { Actions, Effect } from '@ngrx/effects';
import { Action } from '@ngrx/store';
import { of } from 'rxjs/observable/of';
import { Observable } from 'rxjs/Rx';

import * as countryAction from './country.action';
import { Country } from './../../models/country';
import { CountryService } from '../../core/services/country.service';

@Injectable()
export class CountryEffects {

    @Effect() getAllPerRegion$: Observable<Action> = this.actions$.ofType(countryAction.SELECTREGION)
        .switchMap((action: Action) =>
            this.countryService.getAllPerRegion((action as countryAction.SelectRegionAction).region.name)
                .map((data: Country[]) => {
                    const region = { name: (action as countryAction.SelectRegionAction).region.name, expanded: true, countries: data};
                    return new countryAction.SelectRegionCompleteAction(region);
                })
                .catch(() => {
                    return of({ type: 'getAllPerRegion$' })
                })
        );
    constructor(
        private countryService: CountryService,
        private actions$: Actions
    ) { }
}

Step 6: Implement the reducers

Implement the reducer to change the state when required. The reducer takes an initial state and executes methods matching the defined actions which were dispatched from the components or the effects.

import { CountryState } from './country.state';
import { Region } from './../../models/region';
import * as countryAction from './country.action';

export const initialState: CountryState = {
    regions: [
        { name: 'Africa', expanded:  false, countries: [] },
        { name: 'Americas', expanded: false, countries: [] },
        { name: 'Asia', expanded: false, countries: [] },
        { name: 'Europe', expanded: false, countries: [] },
        { name: 'Oceania', expanded: false, countries: [] }
    ]
};

export function countryReducer(state = initialState, action: countryAction.Actions): CountryState {
    switch (action.type) {

        case countryAction.SELECTREGION_COMPLETE:
            return Object.assign({}, state, {
                regions: state.regions.map((item: Region) => {
                    return item.name === action.region.name ? Object.assign({}, item, action.region ) : item;
                })
            });

        case countryAction.COLLAPSEREGION:
            action.region.expanded = false;
            return Object.assign({}, state, {
                regions: state.regions.map((item: Region) => {
                    return item.name === action.region.name ? Object.assign({}, item, action.region ) : item;
                })
            });

        default:
            return state;

    }
}

Step 7: Configure the module.

Important here is how the StoreModule.forFeature is configured. The configuration must match the definitions in the components which use the store.

import { CommonModule } from '@angular/common';
import { HttpClientModule } from '@angular/common/http';
import { NgModule } from '@angular/core';
import { FormsModule } from '@angular/forms';

import { CountryComponent } from './components/country.component';
import { CountryRoutes } from './country.routes';

import { EffectsModule } from '@ngrx/effects';
import { StoreModule } from '@ngrx/store';
import { CountryEffects } from './store/country.effects';
import { countryReducer } from './store/country.reducer';
import * as countryAction from './store/country.action';

@NgModule({
    imports: [
        CommonModule,
        FormsModule,
        HttpClientModule,
        CountryRoutes,
        StoreModule.forFeature('world', {
            regions: countryReducer, countryAction
        }),
        EffectsModule.forFeature([CountryEffects])
    ],

    declarations: [
        CountryComponent
    ],

    exports: [
        CountryComponent
    ]
})

export class CountryModule { }

Step 8: Create the component

Create the component which uses the store. The constructor configures the store matching the module configuration from the forFeature and the state as required. User actions dispatch events using the actions, which if required calls the an effect function, which then calls an action and then a reducer function which changes the state.

import { Component, OnInit } from '@angular/core';
import { Store } from '@ngrx/store';
import { Observable } from 'rxjs/Observable';

import { CountryState } from '../store/country.state';
import * as CountryActions from '../store/country.action';
import { Country } from './../../models/country';
import { Region } from './../../models/region';

@Component({
    selector: 'app-country-component',
    templateUrl: './country.component.html',
    styleUrls: ['./country.component.scss']
})

export class CountryComponent implements OnInit {

    public async: any;

    regionsState$: Observable<CountryState>;

    constructor(private store: Store<any>) {
        this.regionsState$ = this.store.select<CountryState>(state => state.world.regions);
    }

    ngOnInit() {
        this.store.dispatch(new CountryActions.SelectAllAction());
    }

    public getCountries(region: Region) {
        this.store.dispatch(new CountryActions.SelectRegionAction(region));
    }

    public collapse(region: Region) {
         this.store.dispatch(new CountryActions.CollapseRegionAction(region));
    }
}

Step 9: Use the state objects in the HTML template.

It is important not to forget to use the async pipe when using the state from ngrx. Now the view is independent from the API calls and when the state is changed, it is automatically updated, or other components which use the same state.

<div class="container-fluid">
    <div class="row" *ngIf="(regionsState$|async)?.regions?.length > 0">
        <div class="table-responsive">
            <table class="table">
                <thead>
                    <tr>
                        <th>#</th>
                        <th>Name</th>
                        <th>Population</th>
                        <th>Capital</th>
                        <th>Flag</th>
                    </tr>
                </thead>
                <tbody>
                    <ng-container *ngFor="let region of (regionsState$|async)?.regions; let i = index">
                        <tr>
                            <td class="text-left td-table-region" *ngIf="!region.expanded">
                                <span (click)="getCountries(region)">►</span>
                            </td>
                            <td class="text-left td-table-region" *ngIf="region.expanded">
                                <span type="button" (click)="collapse(region)">▼</span>
                            </td>
                            <td class="td-table-region">{{region.name}}</td>
                            <td class="td-table-region"> </td>
                            <td class="td-table-region"> </td>
                            <td class="td-table-region"> </td>
                        </tr>
                        <ng-container *ngIf="region.expanded">
                            <tr *ngFor="let country of region.countries; let i = index">
                                <td class="td-table-country">    {{i + 1}}</td>
                                <td class="td-table-country">{{country.name}}</td>
                                <td class="td-table-country" >{{country.population}}</td>
                                <td>{{country.capital}}</td>
                                <td><img width="100" [src]="country.flag"></td>
                            </tr>
                        </ng-container>
                    </ng-container>                                         
                </tbody>
            </table>
        </div>
    </div>

    <!--▼ ►   <span class="glyphicon glyphicon-ok" aria-hidden="true" style="color: darkgreen;"></span>-->
    <div class="row" *ngIf="(regionsState$|async)?.regions?.length <= 0">
        <span>No items found</span>
    </div>
</div>

Redux DEV Tools

The redux-devtools chrome extension is really excellent. Add this to Chrome and start the application.

When you start the application, and open it in Chrome, and the Redux state can be viewed, explored changed and tested. This gives you an easy way to view the state and also display what happened inside the application. You can even remove state changes using this tool, too see a different history and change the value of the actual state.

The actual state can be viewed:

Links:

https://github.com/ngrx

https://egghead.io/courses/getting-started-with-redux

http://redux.js.org/

https://github.com/ngrx/platform/blob/master/docs/store-devtools/README.md

https://chrome.google.com/webstore/detail/redux-devtools/lmhkpmbekcpmknklioeibfkpmmfibljd?hl=en

https://restcountries.eu/

http://onehungrymind.com/build-better-angular-2-application-redux-ngrx/

https://egghead.io/courses/building-a-time-machine-with-angular-2-and-rxjs

Advertisements

3 comments

  1. Hi Damien

    You write “A store exists per module”. Correctly you should say that there is just one store per application while you can register additional reducers for your Feature-Modules with StoreModule.forFeature() per module.

    I like it that ngrx currently gets more attention. A lot of people did not know about it or didn`t have used it yet. But its totally worth to be used in a lot of web applications because it makes state management so much easier. I use it in production for over 1 year.

    Cheers
    Philip

    1. Hi Philip

      Thanks for the feedback. I’ll update

      Greetings Damien

  2. […] Getting started with Angular and Redux (Damien Bowden) […]

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s

%d bloggers like this: