Using NgRx Effects for Data Loading in an Ionic & Angular Application

Using NgRx Effects for Data Loading in an Ionic & Angular Application

Follow Josh Morony on

As we have discussed quite a bit now, the idea with state management solutions like Redux and NgRx is to dispatch actions which are interpreted by reducers in order to produce a new state. In a simple scenario, as was the case for the last NgRx tutorial, we might dispatch a CreateNote action and supply it with the note we want to create, which then allows the reducer to appropriately add this data to the new state:

  createNote(title): void {
    let id = Math.random()
      .toString(36)
      .substring(7);

    let note = {
      id: id.toString(),
      title: title,
      content: ""
    };

    this.store.dispatch(new NoteActions.CreateNote({ note: note }));
  }

However, things are not always so simple. If we consider the context for this tutorial (loading data), there is a bit more that needs to happen. As well as dispatching the action for loading data, we also need to trigger some kind of process for actually loading the data (usually by making an HTTP request to somewhere).

This is where we can make use of NgRx Effects which allows us to create “side effects” for our actions. Put simply, an @Effect() will allow us to run some code in response to a particular action being triggered. In our case, we will use this concept to trigger our HTTP request to load the data.

Another aspect of loading data is that it is asynchronous and may not always succeed, and a single LoadData action isn’t enough to cover all possible scenarios. This is something we covered in detail in the [Ionic/StencilJS/Redux version of this tutorial], but the basic idea is to have three actions for data loading:

  • LoadDataBegin
  • LoadDataSuccess
  • LoadDataFailure

We will initially trigger the LoadDataBegin action. We will have an @Effect() set up to listen for this particular action, which will trigger our HTTP request. If this request is successfully executed, we will then trigger the LoadDataSuccess action automatically within the effect, or if an error occurs we will trigger the LoadDataFailure action.

If you would like more context to this approach, or if you do not already understand how to set up NgRx in an Ionic/Angular application, I would recommend reading the tutorials below:

Before We Get Started

This is an advanced tutorial, and it assumes that you are already comfortable with Ionic/Angular and that you also already understand the basics of NgRx and state management. If you are not already comfortable with these concepts, please start with the tutorials I linked above.

This tutorial will also assume that you already have an Ionic/Angular application with NgRx set up, we will just be walking through the steps for setting up this data loading process with NgRx Effects.

Finally, you will need to make sure that you have installed NgRx Effects in your project with the following command:

npm install --save @ngrx/effects

We will walk through the configuration for NgRx Effects later in the tutorial.

1. Create the Test Data

To begin with, we are going to need some data to load in via an HTTP request. We will just be using a local JSON file to achieve that, but if you prefer you could easily swap this approach out with a call to an actual server.

Create a file at src/assets/test-data.json with the following data:

{
  "items": [
    "car",
    "bike",
    "shoe",
    "grape",
    "phone",
    "bread",
    "valyrian steel",
    "hat",
    "watch"
  ]
}

2. Create the Actions

We are going to create our three actions for data loading first, which will follow a similar format to any other action we might define (again, if you are unfamiliar with this please read the tutorials linked in the introduction).

Create a file at src/app/actions/data.actions.ts and add the following:

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

export enum ActionTypes {
  LoadDataBegin = "[Data] Load data begin",
  LoadDataSuccess = "[Data] Load data success",
  LoadDataFailure = "[Data] Load data failure"
}

export class LoadDataBegin implements Action {
  readonly type = ActionTypes.LoadDataBegin;
}

export class LoadDataSuccess implements Action {
  readonly type = ActionTypes.LoadDataSuccess;

  constructor(public payload: { data: any }) {}
}

export class LoadDataFailure implements Action {
  readonly type = ActionTypes.LoadDataFailure;

  constructor(public payload: { error: any }) {}
}

export type ActionsUnion = LoadDataBegin | LoadDataSuccess | LoadDataFailure;

The main thing to note here is that our LoadDataSuccess and LoadDataFailure actions will include a payload - we will either pass the loaded data along with LoadDataSuccess or we will pass the error to LoadDataFailure.

3. Create the Reducer

Next, we will define our reducer for the data loading. Again, this will look similar to any normal reducer, but this will highlight a particularly interesting aspect of what we are doing (which we will talk about in just a moment).

Create a file at src/app/reducers/data.reducer.ts and add the following:

import * as fromData from "../actions/data.actions";

export interface DataState {
  items: string[];
  loading: boolean;
  error: any;
}

export const initialState: DataState = {
  items: [],
  loading: false,
  error: null
};

export function reducer(
  state = initialState,
  action: fromData.ActionsUnion
): DataState {
  switch (action.type) {
    case fromData.ActionTypes.LoadDataBegin: {
      return {
        ...state,
        loading: true,
        error: null
      };
    }

    case fromData.ActionTypes.LoadDataSuccess: {
      return {
        ...state,
        loading: false,
        items: action.payload.data
      };
    }

    case fromData.ActionTypes.LoadDataFailure: {
      return {
        ...state,
        loading: false,
        error: action.payload.error
      };
    }

    default: {
      return state;
    }
  }
}

export const getItems = (state: DataState) => state.items;

First of all, we’ve defined the structure of our DataState to include an items property that will hold our loaded data, a loading boolean that will indicate whether the data is currently in the process of being loaded or not, and an error that will contain any error that occurred.

The three actions are being handled in a reasonably predictable manner. The LoadDataBegin action returns a new state with the loading boolean set to true and the error to null. The LoadDataSuccess and LoadDataFailure actions are more interesting. They are both using the payload provided to the action, either to add items to the new state or an error. But as of yet, we aren’t actually seeing where this data is coming from - nowhere in our actions or reducer have we done anything like launch an HTTP request. This is where our “side effect” created with NgRx effects will come into play - this will be triggered upon LoadDataBegin being dispatched, and it will be this side effect that is responsible for triggering the further two actions (and supplying them with the appropriate payload).

We also define a getItems function at the bottom of this file to return the items state specifically - we will use this in a selector later but it isn’t really important to the purpose of this tutorial.

4. Create the Service

Before we create our effect to handle loading the data, we need to write the code responsible for loading the data. To do this, we will implement a service like the one below:

Create a service at src/app/services/data.service.ts that reflects the following:

import { Injectable } from "@angular/core";
import { HttpClient } from "@angular/common/http";

@Injectable({
  providedIn: "root"
})
export class DataService {
  constructor(private http: HttpClient) {}

  loadData() {
    return this.http.get("/assets/test-data.json");
  }

}

This service quite simply provides a loadData method that will return our HTTP request to load the data as an Observable, which can then be used in the @Effect() we will create.

5. Create the Effect

Now we get to the key element of this process. There is a bit going on in the code below, but the key idea is that this @Effect will be triggered whenever the LoadDataBegin action is dispatched.

Create a file at src/app/effects/data.effects.ts and add the following:

import { Injectable } from "@angular/core";
import { Actions, Effect, ofType } from "@ngrx/effects";
import { map, switchMap, catchError } from "rxjs/operators";
import { of } from "rxjs";

import { DataService } from "../services/data.service";
import * as DataActions from "../actions/data.actions";

@Injectable()
export class DataEffects {
  constructor(private actions: Actions, private dataService: DataService) {}

  @Effect()
  loadData = this.actions.pipe(
    ofType(DataActions.ActionTypes.LoadDataBegin),
    switchMap(() => {
      return this.dataService.loadData().pipe(
        map(data => new DataActions.LoadDataSuccess({ data: data })),
        catchError(error =>
          of(new DataActions.LoadDataFailure({ error: error }))
        )
      );
    })
  );
}

We inject Actions from @ngrx/effects and then we use that to listen for the actions that have been triggered. Our loadData class member is decorated with the @Effect() decorator, and then we pipe the ofType operator onto the Observable of actions provided by Actions to listen for actions specifically of the type LoadDataBegin.

We then use a switchMap to return a new observable, which is created from our HTTP request to load the data. We then add two additional operators onto this, map and catchError. If the map is triggered then it means the data has been loaded successfully, and so we create a new LoadDataSuccess action and supply it with the data that was loaded. If the catchError is triggered it means that the request (and thus the observable stream) has failed. We then return a new observable stream by using of and we trigger the LoadDataFailure action, supplying it with the relevant error.

This is a tricky bit of code, and may be hard to understand if you don’t have a solid grasp of Observables/RxJS. If you are struggling with this, just keep that key idea in mind: Listen for LoadDataBegin, trigger LoadDataSuccess if the HTTP request succeeds, trigger LoadDataFailure if it fails.

We will need to make use of this effect elsewhere (and your application may include other effects), so we will create an index file that exports all of our effects for us.

Create a file at src/app/effects/index.ts and add the following:

import { DataEffects } from "./data.effects";

export const effects: any[] = [DataEffects];

If you created any more effects, you could add them to the effects array here.

6. Configure Everything

We are mostly done with setting up our data loading process now, but we do have a few loose ends to tie up. We need to set up our reducer properly and configure some stuff in our root module. Let’s take care of that now.

Modify src/app/reducers/index.ts to reflect the following:

import {
  ActionReducer,
  ActionReducerMap,
  createFeatureSelector,
  createSelector,
  MetaReducer
} from "@ngrx/store";
import { environment } from "../../environments/environment";

import * as fromData from "./data.reducer";

export interface AppState {
  data: fromData.DataState;
}

export const reducers: ActionReducerMap<AppState> = {
  data: fromData.reducer
};

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

export const getDataState = (state: AppState) => state.data;
export const getAllItems = createSelector(
  getDataState,
  fromData.getItems
);

Again, nothing new here. We just add our data reducer to our reducers (of which we only have one anyway in this example), and we also create a selector at the bottom of this file so that we can grab just the items if we wanted. For the sake of this demonstration, we will just be looking at the result from getDataState which will allow us to see all three properties in our data state: items, loading, and error.

Modify src/app/app.module.ts to include the EffectsModule:

import { NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { RouteReuseStrategy } from "@angular/router";

import { IonicModule, IonicRouteStrategy } from "@ionic/angular";
import { SplashScreen } from "@ionic-native/splash-screen/ngx";
import { StatusBar } from "@ionic-native/status-bar/ngx";

import { HttpClientModule } from "@angular/common/http";

import { AppComponent } from "./app.component";
import { AppRoutingModule } from "./app-routing.module";
import { StoreModule } from "@ngrx/store";
import { EffectsModule } from "@ngrx/effects";
import { reducers, metaReducers } from "./reducers";
import { effects } from "./effects";

@NgModule({
  declarations: [AppComponent],
  entryComponents: [],
  imports: [
    BrowserModule,
    HttpClientModule,
    IonicModule.forRoot(),
    AppRoutingModule,
    StoreModule.forRoot(reducers, { metaReducers }),
    EffectsModule.forRoot(effects)
  ],
  providers: [
    StatusBar,
    SplashScreen,
    { provide: RouteReuseStrategy, useClass: IonicRouteStrategy }
  ],
  bootstrap: [AppComponent]
})
export class AppModule {}

There are a couple of things going on here, including setting up the HttpClientModule and the StoreModule, but these things should already be in place. In the context of this tutorial, the important part is that we are adding the EffectsModule to our imports and supplying it with the array of effects that we exported from the index.ts file for our effects folder.

Keep in mind that although we are using forRoot for both the Store and the Effects in this example (which will make these available application-wide) it is also possible to use forFeature instead if you want to organise your code into various modules.

7. Load Data & Listen to State Changes

Everything is ready to go now, but we still need to make use of our state in some way. What we are going to do now is extend our DataService to provide a method that dispatches the initial LoadDataBegin action. We will also create some methods to return various parts of our state tree. You might wish to do something a bit different (for example, you might wish to trigger the LoadDataBegin action elsewhere), this is just to demonstrate how the data loading process works.

Modify src/app/services/data.service.ts to reflect the following:

import { Injectable } from "@angular/core";
import { HttpClient } from "@angular/common/http";
import { Store } from "@ngrx/store";

import * as DataActions from "../actions/data.actions";
import { AppState, getAllItems, getDataState } from "../reducers";

@Injectable({
  providedIn: "root"
})
export class DataService {
  constructor(private store: Store<AppState>, private http: HttpClient) {}

  loadData() {
    return this.http.get("/assets/test-data.json");
  }

  load() {
    this.store.dispatch(new DataActions.LoadDataBegin());
  }

  getData() {
    return this.store.select(getDataState);
  }

  getItems() {
    return this.store.select(getAllItems);
  }
}

Now we have the ability to just call the load() method on our data service to trigger the loading process. We will just need to trigger that somewhere (e.g. in the root component):

import { Component } from "@angular/core";
import { DataService } from "./services/data.service";

@Component({
  selector: "app-root",
  templateUrl: "app.component.html"
})
export class AppComponent {
  constructor(private dataService: DataService) {
    this.dataService.load();
  }
}

and then we can subscribe to getData(), or any other method we created for grabbing state from our state tree, to do something with our state:

import { Component, OnInit } from "@angular/core";
import { DataService } from "../services/data.service";

@Component({
  selector: "app-home",
  templateUrl: "home.page.html",
  styleUrls: ["home.page.scss"]
})
export class HomePage implements OnInit {
  constructor(private dataService: DataService) {}

  ngOnInit() {
    this.dataService.getData().subscribe(data => {
      console.log(data);
    });
  }
}

In this case, we are just logging out the result, which would look like this:

showing successful ngrx data load

This is what a successful load would look like, but just for the sake of experiment, you might want to try causing the request to fail (by providing an incorrect path to the JSON file for example). In that case, you would see that the items array remains empty, and that there is an error present in the state:

showing unsuccessful ngrx data load

Summary

As is the case with NgRx/Redux in general, the initial set up requires a considerable amount more work, but the end result is nice and easy to work with. With the structure above, we now have a reliable way to load data into our application, a way to check if the data is in the process of being loaded or not, a way to make state available wherever we need it in our application, and to also gracefully handle errors that occur.

Check out my latest videos: