Using NgRx for State Management in an Ionic Application
This is part of a series, check out the other parts below:
In this tutorial, we are going to tackle the basics of using NgRx for state management in an Ionic/Angular application.
If you are unfamiliar with the general concept of State Management/Redux/NgRx I would recommend watching this video: What is Redux? as a bit of a primer. The video is centered around Redux, but since NgRx is inspired by Redux the concepts are mostly the same. NgRx is basically an Angular version of Redux that makes use of RxJS and our good friends observables.
To quickly recap the main points in the Redux video, we might want to use a state management solution like NgRx/Redux because:
- It provides a single source of truth for the state of your application
- It behaves predictably since we only create new state through explicitly defined actions
- Given the structured and centralised nature of managing state, it also creates an environment that is easier to debug
and the general concept behind these state management solutions is to:
- Have a single source of "state" that is read-only
- Create actions that describe some intent to change that state (e.g. adding a new note, or deleting a note)
- Create reducers that take the current state and an action, and create a new state based on that (e.g. combining the current state with an action to add a new note, would return a new state that includes the new note)
To demonstrate using some pseudocode (I am just trying to highlight the basic concept here, not how you would actually do this with NgRx), we might have some "state" that looks like this:
{
notes: [
{title: 'hello'},
{title: 'there'}
],
order: 'alphabetical',
nightMode: false
}
The data above would be our state/store that contains all of the "state" for our application. The user might want to toggle nightMode
at some point, or add new notes, or change the sort order, so we would create actions to do that:
ToggleNightMode
CreateNote
DeleteNote
ChangeOrder
An action by itself just describes intent. To actually do something with those actions we might use a reducer that looks something like this:
function reducer(state, action) {
switch (action) {
case ToggleNightMode: {
return {
// New state with night mode toggled
};
}
default: {
return; // State with no changes
}
}
}
NOTE: This is just pseudocode, do not attempt to use this in your application.
This reducer function takes in the current state, and the action, and in the case of the ToggleNightMode
action being supplied it will return a new state with the nightMode
property toggled.
Outline
Before We Get Started
We will be taking a look at converting a typical Ionic/Angular application to use NgRx for state management. To do that, we are going to take the application built in this tutorial: Building a Notepad Application from Scratch with Ionic and add NgRx to it. You don't need to complete that tutorial first, but if you want to follow along step-by-step with this tutorial you should have a copy of it on your computer.
We are going to keep this as bare bones as possible and just get a basic implementation up and running - mostly we will be focusing on the ability to create notes and delete notes. My main goal with this tutorial is to help give an understanding of the basic ideas behind NgRx, and what it looks like.
There is so much you can achieve with NgRx, and you can create some very advanced/powerful implementations, but that does come with an associated level of complexity. Looking at implementations of NgRx can be quite intimidating, so I've tried to keep this example as basic as possible (whilst still adhering to a good general structure). In the future, we will cover more complex implementations - if there is something, in particular, you would like to see covered, let me know in the comments.
Finally, it is worth keeping in mind that solutions like NgRx and Redux are not always necessary. NgRx and Redux are both very popular (for good reason), but they are not a "standard" that everyone needs to be using in all applications. Simple applications might not necessarily realise much of a benefit through using this approach.
1. Installing NgRx
We can easily install NgRx in an existing Angular application through the ng add
command:
ng add @ngrx/store
As well as installing the @ngrx/store
package it will also create a reducers folder with an index.ts file that looks like this:
import {
ActionReducer,
ActionReducerMap,
createFeatureSelector,
createSelector,
MetaReducer,
} from '@ngrx/store';
import { environment } from '../../environments/environment';
export interface State {}
export const reducers: ActionReducerMap<State> = {};
export const metaReducers: MetaReducer<State>[] = !environment.production
? []
: [];
We will be adding onto this implementation throughout the tutorial, but we're mostly just going to leave it alone for now.
One thing in particular that we haven't covered yet, but you may notice here, is the concept of a "meta reducer". A regular reducer is responsible for taking in the state and an action and returning a new state. A meta reducer would take in a reducer as an argument and return a new reducer (kind of like how we can pipe
operators onto an observable
and return an altered observable
if you are familiar with that concept). We won't be using this concept in this tutorial, but you could use a meta reducer to do things like create a logging service for debugging (e.g. create a meta reducer that logs out some value every time the ToggleNightMode
action is triggered).
The ng add
command also adds the following line to your app.module.ts file:
StoreModule.forRoot(reducers, { metaReducers });
This takes a global approach to implementing state management, but if you prefer you can also use StoreModule.forFeature
in your individual lazy loaded modules to help keep things separate. There are many approaches you could take to structuring NgRx in your application. As I mentioned, I am trying to keep things simple here, so I would recommend taking a look at a few examples to see what style suits you best.
We are also going to store our actions in an actions folder, but the command doesn't create that for us automatically. Let's do that now.
Create an actions folder and note.actions.ts file at src/app/actions/note.actions.ts
Let's start looking into how we can implement our first action.
2. Creating the Actions
To create an action we create classes that implement Action
from the NgRx library. As we discussed, an action just describes intent. We won't be adding any code to actually do anything here, we just want to describe what we want to do.
Modify src/app/actions/note.actions.ts to reflect the following:
import { Action } from "@ngrx/store";
import { Note } from "../interfaces/note";
export enum ActionTypes {
CreateNote = "[Notes Service] Create note",
DeleteNote = "[Notes Service] Delete note"
}
export class CreateNote implements Action {
readonly type = ActionTypes.CreateNote;
constructor(public payload: { note: Note }) {}
}
export class DeleteNote implements Action {
readonly type = ActionTypes.DeleteNote;
constructor(public payload: { note: Note }) {}
}
export type ActionsUnion = CreateNote | DeleteNote;
First, we have an ActionTypes
enumerated type that lists our various actions related to notes, and a description of what the action will do. Since these actions will be triggered from our existing notes service (you can trigger these actions elsewhere if you like) we make a note of the source of the action in the square brackets. This is purely to be more explicit/descriptive, it doesn't serve a functional purpose.
We then create classes for each of the actions. It implements Action
, it defines a type
so we can tell what kind of action it is, and we can optionally supply a payload for that action. In the case of creating and deleting notes we will need to send a payload of data to correctly add or delete a note, but some actions (like toggling nightMode
) would not require a payload.
Finally, we have the ActionsUnion
which exports every action created in this file.
3. Creating the Reducers
As we now know, actions don't do anything by themselves. This is where our reducers come in. They will take the current state, and an action, and give us a new state. Let's implement our first reducer now.
Create a file at src/app/reducers/note.reducer.ts and add the following:
import * as fromNote from '../actions/note.actions';
import { Note } from '../interfaces/note';
export interface NoteState {
data: Note[];
}
export const initialState: NoteState = {
data: [],
};
export function reducer(
state = initialState,
action: fromNote.ActionsUnion
): NoteState {
switch (action.type) {
case fromNote.ActionTypes.CreateNote: {
return {
...state,
data: [...state.data, action.payload.note],
};
}
case fromNote.ActionTypes.DeleteNote: {
return {
...state,
...state.data.splice(state.data.indexOf(action.payload.note), 1),
};
}
default: {
return state;
}
}
}
Things are starting to look a little bit more complex now, so let's break it down. There is some stuff we are going to need in our reducer from our note actions that we just created, so we import everything from that actions file as fromNote
so that we can make use of it here (this saves us from having to import everything that we want to use from that file individually).
We define the structure or "shape" of our note state as well as supply it with an initial state:
export interface NoteState {
data: Note[];
}
export const initialState: NoteState = {
data: [],
};
The only data we are interested in are the notes themselves which will be contained under data
, but we could also add additional state related to notes here if we wanted (like sort order, for example).
The last bit is the reducer function itself:
export function reducer(
state = initialState,
action: fromNote.ActionsUnion
): NoteState {
switch (action.type) {
case fromNote.ActionTypes.CreateNote: {
return {
...state,
data: [...state.data, action.payload.note],
};
}
case fromNote.ActionTypes.DeleteNote: {
return {
...state,
...state.data.splice(state.data.indexOf(action.payload.note), 1),
};
}
default: {
return state;
}
}
}
As you can see, the arguments for the reducer
function are the state and the specific action which needs to be one of all the possible note related actions which we defined in our actions file. All we are doing here is switching between the possible actions, and we handle each action differently. Although we might run different code, the goal is the same: to return the new state that we want as a result of the action.
In the case of the CreateNote
action, we want to return all of the existing state, but we also want the data
to contain our new note (as well as all of the existing notes). We use the spread operator ...
to return a new state object containing all of the same properties and data as the existing state, except by specifying an additional data
property we can overwrite the data
property in the new state. To simplify that statement a bit, this:
return {
...state,
};
Basically means "take all of the properties out of the state
object and add them to this object". In effect, it is the same as just doing this:
return state;
Except that we are creating a new object. This:
return {
...state,
data: [...state.data, action.payload.note],
};
Basically means "take all of the properties out of the state
object and add them to this object, except replace the data
property with this new data instead". We keep everything from the existing state, except we overwrite the data
property. We do still want all of the existing notes to be in the array in addition to the new one, so we again use the spread operator (this time just on the data
) to unpack all of the existing notes into a new array, and then add our new one.
To reiterate, this:
[...state.data];
would mean "create a new array and add all of the elements contained in the data
array to this array" which, in effect, is the same as just using state.data
directly (except that we are creating a new array with those same value). This:
[...state.data, action.payload.note];
means "create a new array and add all of the elements contained in the data
array to this array, and then add action.payload.note
as another element in the array. To simplify even further, let's pretend that we are just dealing with numbers here. If state.data
was the array [1, 2, 3]
, and action.payload.note
was the number 7
, then the code above would create this array:
[1, 2, 3, 7];
Hopefully that all makes sense. Once again, the role of our reducer is to modify our existing state in whatever way we want (based on the action it receives) and then return that as the new state.
Before we can make use of our actions/reducers, we need to set them up in our index.ts file.
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 fromNote from './note.reducer';
export interface AppState {
notes: fromNote.NoteState;
}
export const reducers: ActionReducerMap<AppState> = {
notes: fromNote.reducer,
};
export const metaReducers: MetaReducer<AppState>[] = !environment.production
? []
: [];
The important part that has changed here is:
import * as fromNote from './note.reducer';
export interface AppState {
notes: fromNote.NoteState;
}
export const reducers: ActionReducerMap<AppState> = {
notes: fromNote.reducer,
};
We set up our overall application state on the AppState
interface (this is named State
by default). We are just working with a single notes reducer here, but you could also have additional state, for example:
export interface AppState {
notes: fromNote.NoteState;
photos: fromPhoto.PhotoState;
}
Our store or "single source of truth" creates a nested/tree structure that might look something like this:
{
notes: [
{title: 'hello'},
{title: 'there'}
],
photos: [
{url: ''},
{url: ''}
]
}
When we are creating our notes actions/reducers we are just working within the notes
"sub-tree" but it is still a part of the entire application state tree.
We also add the reducer we created for our notes under the notes
property in the reducers
constant that is exported (and is in turn used in our app.module.ts by StoreModule.forRoot()
).
4. Creating Selectors
The last thing we are going to do before making use of our new state management solution in our notes service is create some selectors. A selector will allow us to read state from our store.
To create a selector, we can use createSelector
which is provided by NgRx. We just supply it with the state we want to select from (e.g. the "sub-tree" of our state that we want to access), and a function that returns the specific data that we are interested in.
Add the following to the bottom of src/app/reducers/note.reducer.ts:
export const getNotes = (state: NoteState) => state.data;
export const getNoteById = (state: NoteState, props: { id: string }) =>
state.data.find((note) => note.id === props.id);
We are creating two functions here to use with createSelector
. The getNotes
function, when given the notes
from our state tree, will return just the data
property (which is the one we are interested in, since it is what actually contains the notes data).
The getNoteById
function will take in additional props
that can be supplied when attempting to select something from the store, which will allow us to provide an id
. This function will then look for a specific note in the data
that matches that id
and return just that note.
Add the following to the bottom of src/app/reducers/index.ts:
export const getNoteState = (state: AppState) => state.notes;
export const getAllNotes = createSelector(getNoteState, fromNote.getNotes);
export const getNoteById = createSelector(getNoteState, fromNote.getNoteById);
With our functions created, we now just need to use them to create our selectors with createSelector
. We first create a function called getNoteState
to return the notes
portion of our state tree. We then supply that, and the functions we just created, as arguments to createSelector
in order to create our selector functions.
5. Accessing State and Dispatching Actions
Now everything finally comes together, and maybe you can see some of the benefit of doing all of this leg work up front. With our selectors created, we can easily get the data we want from our store wherever we like in our application. We can also easily make use of our CreateNote
and DeleteNote
actions.
To finish things off, we are going to keep the existing structure of the notes application, and just modify the methods in the NotesService
.
Modify src/app/services.notes.service.ts to reflect the following:
import { Injectable } from "@angular/core";
import { Store } from "@ngrx/store";
import { Storage } from "@ionic/storage";
import { Observable } from "rxjs";
import { Note } from "../interfaces/note";
import * as NoteActions from "../actions/note.actions";
import { AppState, getAllNotes, getNoteById } from "../reducers";
@Injectable({
providedIn: "root"
})
export class NotesService {
public notes: Observable<Note[]>;
constructor(private storage: Storage, private store: Store<AppState>) {
this.notes = this.store.select(getAllNotes);
}
getNote(id: string): Observable<Note> {
return this.store.select(getNoteById, {
id: id
});
}
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 }));
}
deleteNote(note): void {
this.store.dispatch(new NoteActions.DeleteNote({ note: note }));
}
}
To get access to our notes data, we just call this.store.select(getAllNotes)
using the selector we created and it will return an observable. This observable will update any time the data in the store changes.
To get a specific note, we use our getNoteById
selector, but since that selector also uses additional props (an id
in this case) we pass that data along too:
return this.store.select(getNoteById, {
id: id,
});
This will allow us to grab a specific note. Then we just have our createNote
and deleteNote
methods which are able to create or delete notes just be triggering the appropriate action and passing the note along with it:
this.store.dispatch(new NoteActions.CreateNote({ note: note }));
this.store.dispatch(new NoteActions.DeleteNote({ note: note }));
Since we now have our notes
class member set up as an observable now, if we want to be able to display the data it contains in our template we will need to add the async
pipe.
Modify the
<ion-item>
in src/app/home/home.page.html to use theasync
pipe:
<ion-item
button
detail
*ngFor="let note of (notesService.notes | async)"
[routerLink]="'/notes/' + note.id"
routerDirection="forward"
></ion-item>
The ngOnInit
in the detail page will also need to be updated to make use of the observable returned by the getNote
method now (if you have been following along with the notes application tutorial):
Modify
ngOnInit
in src/app/detail/detail.page.ts to reflect the following:
ngOnInit() {
let noteId = this.route.snapshot.paramMap.get("id");
this.notesService.getNote(noteId).subscribe(note => {
this.note = note;
});
}
Summary
Using NgRx looks a lot more complicated than just simply managing state yourself (and it is), but like a lot of things worth doing it's a bit more upfront work for a longer-term payoff.
The state in this example application could rather easily be managed without using an advanced state management solution like NgRx. However, as applications become more complex the upfront work in setting up NgRx can be a great investment.