Loading...

how to manage application state in an angular 9 app using ngrx

Published
11-05-2020

Today, Angular is a very popular Javascript framework as it enables developers of all levels to create applications extremely quickly. However, once an application gets beyond a certain size, managing Application State can become very problematic.

Managing Application State means:

  • making sure local (saved) data agrees with server data;
  • sharing collections between views;
  • tracking user selections so that selections such as country, client etc are kept between views;
  • ensuring unsaved local data is stored as the user moves between views;
  • recognising whether a user is logged in and if so what permissions they have;
  • etc 

In fact, most if not all professional Angular developers would agree that managing Application State is what determines the success or failure of an Angular App. If state isn't managed well it can be a major development problem as components have to check in various places to make sure they are showing the correct data, and/or to work out what data to show. As a result, there are often errors and inconsistencies in the application.

RxJS within Angular is very powerful but arguably, of itself has limitations for managing Application State. Redux was originally seen as a solution as it had been successfully used for this purpose in React applications but implementing it in Angular is complex. NgRx was then developed along Redux lines.

introducing NGRX

The following article provides a quick demo on how to get NGRX implemented in an Angular 9 App.

This article is part of a series during which an Angular App has been developed that gets data from a DotNet Core Web API. The starting point for the Angular App can downloaded from here. The API for the data can be downloaded from here. The data structures used in the App are fairly simple so if you don't want to use DotNet Core for the API but want to follow along, you could easily create your own API.

setup

If you have been following along your Angular and API projects should be working together happily and you can skip this section. If you have just downloaded the repos from Github you will need to do the following setup process.

To run from the API you will need to have installed DotNetCore 3.1. If you don't have it installed you can download it from here.

If you are using Visual Studio run the API using F5. Navigate to the country endpoint and you will see something like the following:

Note the port number. You will need it in the next step to configure the Angular project to call the API correctly.

If you are running the API from the command line (open a command prompt at the project level and type "dotnet run"), the API will be running at https://localhost:5001/. 

In the Angular project, edit the "environment.ts" to replace the existing apiUrl line which maps to the internal API. You should replace the existing line with the following, using your port number (either 5001 or whatever Visual Studio allocated - 44399 in the above image), so that it maps to the DotnetCore API:

apiUrl:"https://localhost:44399/"

run the App

Run the API then run the Angular App. Then select "Stocks" view and select a country. You should see the following:

 

add NGRX to the Angular App

In a command prompt type:

npm install @ngrx/store --save
npm install @ngrx/effects --save

Still in a command prompt type:

ng add @ngrx/store

This automates configuring the "app.module.ts", in particular the imports. You will see the following added to the file:

import { StoreModule } from '@ngrx/store';@NgModule({
    imports: [
  ...
      StoreModule.forRoot({}, {})
  ...
  ]
})
 
You will also see that you now have under the "app" folder a new folder called "store" where we can keep all things related to Application State. In the "store" folder there are two sub folders called "actions" and "state".

If you are reading this a long time after the date this article was last updated, you can double check the latest installation instructions by going to the NGRX installation page.

about NGRX

The following diagram is a nice summary of NGRX works:

In this article we will cover all of these parts of NGRX.

create folders

Under the "store" folder create an additional three folders called:

  • effects
  • reducers
  • selectors

create the state model file

In the "state" folder add a class called "state-model.ts". This file will contain an interface for the Application State data model, defaults for the data in Application State, and methods that manage all changes to state. From an Application State perspective our App currently consists of three lists (country, stocks and funds), and a selected country.

To create the interface for the Application State data model and the defaults for the related data, add the following code to the "state-model.ts" file:

import { Country, Stock, Fund } from 'src/app/data/model';

export interface AppState{
    countryList:Country[];
   selectedCountry:number;
   stockList:Stock[];
   fundList:Fund[];
}
export const initialState: AppState = {
   countryList: [], selectedCountry: -1,
   stockList: [],
   fundList: [],
}

actions - when something happens in the App

We create an action class for every single thing that our App can do. Some examples of actions are the following:

  • Getting lists from the server;
  • Creating a new object;
  • Adding a new object to a list;
  • Storing a user selection (from a drop down list etc);
  • Removing an item from a list;
  • etc etc

Actions files are quite simple. All we do is define a unique type for the action using a string, and define a payload if there is one. When we create the type for the action we have to follow a naming convention and put the name of our entity within square brackets.  

create the country actions file

It makes sense to group our Action definitions along (data) entity lines. In the "actions" folder create a file called "country.actions.ts". Add the following code:

import { createAction, props } from '@ngrx/store';
import { Country } from '../../data/model';

export
 const loadCountrys = createAction(

 '[Country] Load Countrys'
);

export
 const loadCountrysSuccess = createAction(

 '[Country] Load Countrys Success',
  props<{ payload: Country[] }>()
);

export
 const loadCountrysFailure = createAction(

  '[Country] Load Countrys Failure',
 props<{ error: any }>()
);

export
 const selectCountry = createAction(

  '[Country] Select Country',
 props<{ selectedCountryId: number }>()
);

create the reducers file

Reducers are the functions that run when an action is dispatched. In the "reducers" folder create a file called "app.reducers.ts". In this file we will create an instantiation of the state data model and define all of the reducers. To start with let's just add the reducers that we want to run when country Actions are dispatched. Add the following code:

import { environment } from'src/environments/environment';
import { Action, MetaReducer, createReducer, on } from '@ngrx/store';
import * as countryActions from '../actions/country.actions';
import { initialState, AppState } from '../state/state-model';

export interface OuterState {

  appState:AppState
}

const appReducer = createReducer(

  initialState,
on(countryActions.loadCountrys, state => ({ ...state })),
on
(countryActions.loadCountrysSuccess, (state, { payload }) => ({ ...state, countryList: payload })),
  on(countryActions.selectCountry, (state, { selectedCountryId }) => ({ ...state, selectedCountry:selectedCountryId })),
)

export function appReducers(appState: AppState | undefined, action: Action) {

  return appReducer(appState, action);
}


export const metaReducers: MetaReducer[] = !environment.production ? [] : [];

amend the module.ts file

Now we have created the reducers file, we need to import the reducers file (containing the state object) into "app.module.ts". Add the following line to import the reducers : 

import * as appReducers from'./store/reducers/app.reducers'; 

And then amend the imports section in the following way:

imports: [...    StoreModule.forRoot({ appState:appReducers.appReducers }),...]})

effects

Effects can be used in variety of circumstances (which are best described in the NGRX docs). We will use them as triggers that run when we are going to go outside the App for data that will change Application State. That is, when we make an http call to get data.

Effects run as a listener for an action. When the action has been dispatched, and it's corresponding Reducer has been run, the Effect is executed. After the Effect has been executed it usually calls another Reducer.

In our Reducer file you can see that the reducer for "loadCountrys" did not have an effect on state. However, the "loadCountrysSuccess" reducer passes data in to create a new version of the "countrys" list. The Effect is what joins the two together. The Effect creates a mechanism through which "loadCountrys" triggers a call to the "financialsService.getCountrys" method, which then gets data from outside the App. If it is successful it passes that data to the "loadCountrysSuccess" Reducer.

Create a file in the "effects" directory called "country.effects.ts" and paste the following code:

import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { EMPTY, Observable } from 'rxjs';
import { map, mergeMap, catchError } from 'rxjs/operators';
import { FinancialsService } from '../../services/financials.service';
import { loadCountrys } from '../actions/country.actions';
import { Country } from '../../data/model';

@Injectable()

export class CountryEffects {
 loadCountrys$ = createEffect(
     () => this.actions$.pipe(
       ofType(loadCountrys),
       mergeMap(() => this.financialsService.getCountrys()
       .pipe(
           map((countrys:Country[]) => ({ type:'[Country] Load Countrys Success', payload: countrys })),
       catchError(() => EMPTY)
     ))
   )
 );

  constructor(

    private actions$: Actions,
   private financialsService: FinancialsService
 ) {}
}



register the effect with the app.module

Add the following lines to the "app.module.ts" file:

import { CountryEffects } from './store/effects/country.effects';
...
imports: [
...,
EffectsModule.forRoot([CountryEffects]),

selects - querying Application State

Selects are how we get the value of a variable from the store (Application State). We can then use that data in a component. For the Country "entity" we need a Select for the "countryList" and a Select for the "selectedCountry" from Application State. In NgRX we can create Selects inline (as we need them in each component), but since we often use the same Select in many different places it's best to define the logic for them in separate files. Then we can call that Select by name whenever we use it and we know the logic will be the same each time.

In the "selects" folder create a file called "country-list.selector.ts" and paste the following code:

import { createSelector } from '@ngrx/store';
import { OuterState } from '../reducers/app.reducers';

export const selectCountryList = (state:OuterState) => state.appState.countryList;
export const getCountryList = createSelector(
    selectCountryList,
    countryList => countryList);

In the "selects" folder create a file called "selected-country.selector.ts" and paste the following code:

import { OuterState } from '../reducers/app.reducers';
import { createSelector } from '@ngrx/store';

export
 const selectSelectedCountry = (state:OuterState) => state.appState.selectedCountry;

export const getSelectedCountry = createSelector(    selectSelectedCountry,
    selectedCountry => selectedCountry
);

connect the "country-list" component to NgRX

Now we need to amend the "country-list" component so that it does the following:

  • dispatches the "GetCountrys" action to the store when the component starts
  • dispatches the "SelectCountry" action to the store when the uses selects a country from the list
  • uses the Select we have defined to get the "countryList" data from the store
  • uses the Select we have defined to get the "selectedCountry" if the "selectedCountry" has already been set before the component loads.

The other significant change from the version we have so far is that we will use an observable of the country list array rather than  

Amend the "country-list.component.ts" file so that it becomes:    

countrys$:Observable;
SelectedCountryId:number;
    
constructor(private store: Store) {

    this.countrys$ = this.store.pipe(select(selectCountryList));
    this.store.dispatch(loadCountrys()); 
}

ngOnInit(): void {

    this.store.pipe(select(selectSelectedCountry)).subscribe(x => {
    this.SelectedCountryId = x;
    });
}

onChange(){

    this.store.dispatch(selectCountry({selectedCountryId:this.SelectedCountryId}));  
}

One change from the previous version of the component is that we are now using an Observable of the "countrys" array rather than the array. This is because "Selects" always return an Observable. Therefore we now have to amend the HTML file of the component to use the async pipe, rather to get the array. Amend the "country-list.component.html" file to the following:

Now we have data in our country list from the store. We are also setting the value of "selectedCountry" in the store, and we are binding the "selectedCountryId" which is the model of the select list to the Store so that the select list is always consistent, and the value the user chooses stays with the App as the user changes views.

create the "stock" actions

In the "actions" folder create a file called "stock.actions.ts" and populate it with the following:

import { createAction, props } from '@ngrx/store';
import { Stock } from '../../data/model';


export
 const loadStocks = createAction(

 '[Stock] Load Stocks',
 props<{ selectedCountryId: number }>()
);

export
 const loadStocksSuccess = createAction(

 '[Stock] Load Stocks Success',
 props<{ payload: Stock[] }>()
);

You can see from the above code we are passing in the "selectedCountryId" as a parameter in the "loadStocks" action and passing in an array of stocks in the "loadStocksSuccess" action.

Now we implement the code to run  in "app.reducers.ts" when the actions are dispatched.  Add the following code into the createReducer method:

 
    on(stockActions.loadStocks, (state) => ({ ...state })),
    on(stockActions.loadStocksSuccess, (state, { payload }) => ({ ...state, stockList: payload })),

and the following into the imports section at the top:

import * as stockActions from '../actions/stock.actions';

In the code above you can see that when "loadStocks" action is called the reducer does not make any change to state. However, when the "loadStocksSuccess" action is called the reducer replaces the stockList in the store with the data passed to the Action. 

create "stockEffects":

Now we need to create the effect which listens for the "loadStocks" action, then calls the "financialsService" to get stocks data from the API, then passes that data to the "loadStocksSuccess" action.

In the "effects" folder create a file called "stock.effects.ts" and add the following code:

import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { EMPTY } from 'rxjs';
import { map, mergeMap, catchError } from 'rxjs/operators';
import { FinancialsService } from '../../services/financials.service';
import { Stock } from '../../data/model';
import { loadStocks } from '../actions/stock.actions';

@Injectable()


export
 class StockEffects {

 loadStocks$ = createEffect(
     () => this.actions$.pipe(
       ofType(loadStocks),
       mergeMap((action) => this.financialsService.getStocks(action.selectedCountryId)
        .pipe(
           map((stocks:Stock[]) => ({ type:'[Stock] Load Stocks Success', payload: stocks })),
       catchError(() => EMPTY)
      ))
   )
 );

  constructor(

    private actions$: Actions,
   private financialsService: FinancialsService
 ) {}
}

Import StockEffects into "app.module.ts" and add it to the array in the EffectsModule.forRoot line.

create the stock-list selector

In the "selectors" folder create a file called "stock-list.selector.ts" and add the following:

import { createSelector } from '@ngrx/store';
import { OuterState } from '../reducers/app.reducers';

export const selectStockList = (state:OuterState) => state.appState.stockList;

export const getStockList = createSelector(

    selectStockList,

  stockList => stockList
);

get stock data from Application State into the "Stock List" component

In this component we need do three things:

  • subscribe to changes in the "selectedCountry" in the store using the Select we defined
  • dispatch the "loadStocks" Action in the store with the "selectedCountry" received
  • get the "stockList" from the store using a Select we defined, and use it to populate our component

In the "stock-list.component.ts" file replace the existing code with the following:

 

import { Component, OnInit } from'@angular/core';
import { Stock } from 'src/app/data/model';

import { Observable } from 'rxjs';
import { Store, select } from '@ngrx/store';
import { OuterState } from 'src/app/store/reducers/app.reducers';
import { selectStockList } from 'src/app/store/selects/stock-list.selector';
import { selectSelectedCountry } from 'src/app/store/selects/selected-country.selector';
import { loadStocks } from 'src/app/store/actions/stock.actions';
@Component({
 selector: 'app-stock-list',
 templateUrl: './stock-list.component.html',
 styleUrls: ['./stock-list.component.scss']
})
export class StockListComponent implements OnInit {
 stocks$:Observable;
 constructor(private store:Store) {
   this.stocks$ = this.store.pipe(select(selectStockList));
 }
  
ngOnInit(): void {

   this.store.pipe(select(selectSelectedCountry)).subscribe(x => {
     this.getData(x);
   });
 }
  getData(countryId:number){
   this.store.dispatch(loadStocks({selectedCountryId:countryId}));
 }
}

To cater for the changes in the data returned to the component replace the contents of "stock-list.component.html" with the following:

 

get fund data from Application State into the "Fund List" component

 

Repeat the process you just used to create the "GetStocks" Action and implement it in the "stock-list" component, to implement a "GetFunds" action and implement it in the "funds-list" component.

tidy Up

The "financialsService" has some functionality we have now implemented using NGRX and so we need to remove it. Remove the following lines of code from "financialsService":

 

selectedCountry = new Subject();
selectCountry(countryId:number){    this.selectedCountry.next(countryId);}


review the App

When you run the App it will look the same as it when you started this article. However, in addition to having just implemented a huge win for future scalability by using NGRX we have also delivered a small improvement in functionality. Now when we select a country in the "Stocks" view, the selection is also kept when we move to "Funds" view and vice versa.

summary

We have used NGRX to implement State Management in our Angular 9 App. We don't have a lot of functionality in our App and so State Management is relatively simple. However, the important thing is that as our App grows more complex we have a solid framework to ensure that State Management is robust. This will go a long way to ensuring we always have a robust, consistent and test-able App.

 

You can download the finished git repository for the Angular App here.


Latest posts