Using NgRx Effects for Data Loading in an Ionic Application
This is part of a series, check out the other parts below:
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:
- State Management with Redux in Ionic & StencilJS: Loading Data (for more context on using three separate actions for data loading)
- Using NgRx for State Management in an Ionic & Angular Application (for more context on the basics of NgRx)
Outline
Source codeBefore 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:
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:
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.