Ionic 3 & Redux
Every application that we write will need to manage state in some capacity. For example, an application needs to know if a user is logged in or not in order to present the right view (login screen or user dashboard).
We can see here that there is a need for the user’s state to be available for different parts of the application.
In Angular 1, there were a few ways in which state could be dealt with.
- Using factory classes to pass data around, this does the job, but it isn’t great.
- Storing data locally; this is a really bad idea.
- Using $rootScope; the worst thing you could possible do.
As more and more applications were built and grew more complex, managing state got exceedingly difficult and various frameworks popped up. Of all those new frameworks, Redux shined as the winner.
Redux allows us to store our application state in a store (more on this later) which can be accessed by any part of our application. The store is a shared container of our application state, in which components can check for the latest state.
Components can subscribe to the store and listen for changes to a particular piece of state in order to update the component’s local state. This is very important to understand, local state vs application state.
Local State vs Application State
Not all states belongs in a Redux Store, this is something really important to understand.
Let’s take a login form as an example. The LoginComponent’s has a username and password and when these values are added by the user, the LoginComponent’s local state changes.
The WelcomePageComponent doesn’t care about the username and password, that doesn’t relate to it, what the WelcomePageComponent does care about is the user. The user would be stored in the application state.
Okay, so how do we use Redux in Ionic 3?
@ngrx is a Redux inspired library that allows us to take advantage of Redux in our Angular/Ionic projects. The main difference between ngrx and Redux is ngrx is implemented using Observables (RXJS), the heart of Angular. Although ngrx is the angular/ionic flavor of redux, once you have understood ngrx, then you have essentially learned Redux.
Before delving further, we need to build up our Redux Vocabulary. Actions, Reducers, Store, State.
There is a beautiful diagram that I have seen on various articles across the web that I think does a wonderful job of explaining Redux.
What is the state?
State is the source of truth when it comes to the data in our application.
What is an action?
Actions are raised by the UI and contain packets of information that are dispatched for processing. An action will have a type and a payload field. The type defines the type of action, and payload, the data associated with this action
To login a user, an action might look something like this
1 2 3 4 |
{ type: USER_LOGIN, payload: {username: 'ghadeer', password: password} } |
NOTE: The latest version of ngrx has has removed payload in support of strongly typed action types.
What is a reducer?
A reducer is a function that takes the previous state and an action, and returns the next state. Reducer’s should never make an API call or mutate data.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
const initialState = { user: null } function userStateReducer(state = initialState, action) { switch (action.type) { case SAVE_PROFILE: return Object.assign({}, action.userData); default: return state } } |
What is the store?
The store is the source of truth when it comes to the state of our application. The store is comprised of different states that include things such as a if a user has logged in, a user’s profile information, recent search results, etc. The store is accessible by any part of the application that is interested in accessing a piece of state.
What is an effect (not in the digram)
Effects can be seen as middleware that are called after an action is dispatched. Effects can do things like making API calls to authenticate the user and dispatch further events (such as success/error).
Enough theory, let’s get our hands dirty.
Let’s create our Ionic 3 application so we can see how we can use redux to login a user and manage the states via redux.
1 |
ionic start ionic-3-redux blank |
Installing our dependencies
1 2 3 |
npm install @ngrx/store --save npm install @ngrx/router-store --save npm install @ngrx/effects --save |
In order for us to leverage Redux, we need three things: Actions, States, and Reducers. Personally, I like to keep related actions, states, and reducers in the same file. Our example will deal with the user logging in and how we can save this state. Let’s define our User model.
Model
Define a user model, taking advantage of typescript’s strong typing: user.ts
1 2 3 4 5 6 |
export interface User { id: string; firstName: string; lastName: string; email: string; } |
For the next few items, we are going to write these in a user.reducer.ts file. Purists may disagree with this approach, however I have found it works well when writing functionality that can be migrated to other applications.
State
We need to define a UserState. Note, because this is a simplified example, we are going to have our UserState contain pieces of information that belong elsewhere (such as whether an http request is loading and error messages).
A well architected app would not do this, but extract those pieces of states to a higher level AppState, however for simplicity we will just put these values in UserState.
Our UserState will have 3 fields:
- loading – Tells us whether the http request to log the user in is in flight.
- error – Tells us whether or not there was an error logging in the user.
- user – Will contain our User object.
1 2 3 4 5 6 7 8 9 10 11 |
export interface UserState { loading: boolean; error: boolean; user: User; } const initialState: UserState = { loading: false, error: false, user: null }; |
Note that we define our state as an interface where we define the fields and field types, and below we set the values of our initial state. Every state should have an initial state.
Now that our project is setup we are going to focus on setting up our central Store. We will define an interface called AppStore. AppStore will contain all of our states. In this case we just have one state.
1 2 3 |
export interface AppStore { userState: UserState } |
Actions
Which actions we want to be handled when a user tries to log in? We would like to trigger an action when the user presses the the login button. In that case, there are two other actions to be considered:
- The user’s login was successful
- The user’s login was not successful
Given these cases, our action types would look like this.
1 2 3 4 5 |
export const UserActionTypes = { USER_LOGIN: 'USER_LOGIN', USER_LOGIN_SUCCESS: 'USER_LOGIN_SUCCESS', USER_LOGIN_ERROR: 'USER_LOGIN_ERROR' } |
We will also need to define an Action class. We create a LoginAction with a default type, the user that will be returned, and the error message if needed.
1 2 3 4 5 6 7 8 |
export class LoginAction implements Action { type = UserActionTypes.USER_LOGIN; user:User; constructor(public username:string, public password:string) { } } |
Reducer
All actions will flow through the reducer and the reducer will then construct the new state of our application.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
export function userReducer(state: any = initialState, action: LoginAction) { switch (action.type) { case UserActionTypes.USER_LOGIN: return {...state, loading: true}; case UserActionTypes.USER_LOGIN_SUCCESS: return {...state, user: action.user, loading: false}; case UserActionTypes.USER_LOGIN_ERROR: return {...state, loading: false, error: true}; default: return state; } } |
What is {…state}
The … is an ES6 operator.
This syntax takes our current state variable and creates a new copy and decorates it with new properties.
We cannot do something like this
1 2 3 4 5 6 7 8 |
export function userReducer(state: any = initialState, action: LoginAction) { .... case UserActionTypes.USER_LOGIN: state.loading = true return state; .... } } |
This is very important to keep in mind. Reducers should never modify the state, that is Redux’s job. A reducer’s job is to just construct the new state based on the new and old information that it has.
Our full user.reducer.ts file
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 |
import {User} from "./user"; import {Action} from "@ngrx/store"; export interface UserState { loading: boolean; error: boolean; user: User; } const initialState: UserState = { loading: false, error: false, user: null }; export const UserActionTypes = { USER_LOGIN: 'USER_LOGIN', USER_LOGIN_SUCCESS: 'USER_LOGIN_SUCCESS', USER_LOGIN_ERROR: 'USER_LOGIN_ERROR' }; export class LoginAction implements Action { type = UserActionTypes.USER_LOGIN; user:User; constructor(public username:string, public password:string) { } } export function userReducer(state: any = initialState, action: LoginAction) { switch (action.type) { case UserActionTypes.USER_LOGIN: return {...state, loading: true}; case UserActionTypes.USER_LOGIN_SUCCESS: return {...state, user: action.user, loading: false}; case UserActionTypes.USER_LOGIN_ERROR: return {...state, loading: false, error: true}; default: return state; } } |
Now we have setup our reducer file, the next thing we need to do is create a UserService that accepts a username and password and logs the user in.
user.service.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
import { Injectable } from '@angular/core'; import { Http } from '@angular/http'; import 'rxjs/add/operator/toPromise'; import {Observable} from "rxjs"; @Injectable() export class UserService { private apiHost: string = 'https://jsonbin.io'; constructor(private http: Http) { } public login(email:string, password:string):Observable { return this.http.get(this.apiHost+"/59e560b708be13271f7df4ff").map((response) => { return response; }) .catch((err) => { throw Observable.throw(err); }); } } |
The UserService is fairly straight forward: we have a login function that takes a username and password and returns the response. But how do we use this UserService so that it can work with our reducer and actions?
This is where @Effect’s come in to play. Like I mentioned before, @Effect’s can be seen as middleware that interact with our actions and reducers.
Let’s write an @Effect that will listen to the login action, and call the login function in our UserService: user.effects.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
import { Injectable } from "@angular/core"; import { Observable } from "rxjs"; import { Actions, Effect } from "@ngrx/effects"; import {UserActionTypes, LoginAction} from "./user.reducer"; import { UserService } from './user.service'; @Injectable() export class UserEffects { constructor(private actions: Actions, private userService: UserService) { } @Effect() login = this.actions.ofType(UserActionTypes.USER_LOGIN) .map( (action: LoginAction) => action) .switchMap(action => this.userService.login(action.username, action.password) .map(response => ({ type: UserActionTypes.USER_LOGIN_SUCCESS, user: response })) .catch(() => Observable.of({ type: UserActionTypes.USER_LOGIN_ERROR, error: 'Error' })) ); } |
This might look intimidating, so let’s break it down.
1 2 3 4 5 6 7 |
@Effect() <strong>// A</strong> login = this.actions.ofType(UserActionTypes.USER_LOGIN) <strong>//B</strong> .map( (action: LoginAction) => action) .switchMap(action => this.userService.login(action.username, action.password) <strong>//C</strong> .map(response => ({ type: UserActionTypes.USER_LOGIN_SUCCESS, user: response }))<strong>//D</strong> .catch(() => Observable.of({ type: UserActionTypes.USER_LOGIN_ERROR, error: 'Error' })) <strong>//E</strong> ); |
A – Use the @Effects decorator to register this effect with ngrx.
B – Match actions of type UserActionTypes.LOGIN, i.e. this @Effect is run when a dispatcher triggers the Login Action.
C – Call the login function.
D – If successful, dispatch success action with result.
E – If request fails, dispatch failed action.
Here we almost have all of the pieces we need to put everything together. Next, we’ll need to register our effects and reducers with our app, let’s head over to app.module.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
import { BrowserModule } from '@angular/platform-browser'; import { ErrorHandler, NgModule } from '@angular/core'; import { IonicApp, IonicErrorHandler, IonicModule } from 'ionic-angular'; import { SplashScreen } from '@ionic-native/splash-screen'; import { StatusBar } from '@ionic-native/status-bar'; import { userReducer } from '../user/user.reducer' import { MyApp } from './app.component'; import { HomePage } from '../pages/home/home'; import {StoreModule} from "@ngrx/store"; import {EffectsModule} from "@ngrx/effects"; import {UserEffects} from "../user/user.effects"; @NgModule({ declarations: [ MyApp, HomePage ], imports: [ BrowserModule, IonicModule.forRoot(MyApp), StoreModule.forRoot({userState: userReducer}), EffectsModule.forRoot([UserEffects]) ], bootstrap: [IonicApp], entryComponents: [ MyApp, HomePage ], providers: [ StatusBar, SplashScreen, {provide: ErrorHandler, useClass: IonicErrorHandler} ] }) export class AppModule {} |
Now, let’s write our LoginComponent
login.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
import { Component } from '@angular/core'; import { NavController } from 'ionic-angular'; import {AppStore} from "../../app/app.store"; import {Store} from "@ngrx/store"; import {LoginAction} from "../../user/user.reducer"; import {HomePage} from "../home/home"; import {Subscription} from "rxjs"; @Component({ selector: 'login', templateUrl: 'login.html' }) export class LoginPage { private username:string; private password:string; private error:boolean; private userStateSubscription:Subscription; constructor(private navCtrl: NavController, private store:Store) { } ionViewDidEnter() { this.userStateSubscription = this.store.select('userState').subscribe(userState => { this.error = userState.error; if (userState.user) { this.navCtrl.push(HomePage); } }); } ionViewDidLeave() { this.userStateSubscription.unsubscribe(); } login() { this.store.dispatch(new LoginAction(this.username, this.password)); } } |
Now, we inject our store and navController into the constructor; the store which contains our userState.
Keep in mind that we also have to hook into two ionic lifecycle events. We subscribe to state changes where we navigate to the home page if the userState contains the user object. We also unsubscribe when we navigate away from this page.
Finally, we dispatch a login event when the user fills out the form. The flow is:
- User Clicks Login.
- Dispatch an event to the store.
- Run through reducer and show the loader.
- Effect is triggered and the UserService is called.
- Effect dispatches the Success or Error action.
- That action flows through our reducer again.
- Assuming the success action is triggered, the reducers updates our state with the user.
- The login component detects the user is populated and navigates to the Welcome page.
login.html
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
<pre class="" ><code><ion-header> <ion-navbar hideBackButton> <ion-title> Login </ion-title> </ion-navbar> </ion-header> <ion-content padding> <ion-row> <ion-col> <ion-list inset> <ion-item> <ion-input placeholder="Username" type="text" [(ngModel)]="username"></ion-input> </ion-item> <ion-item> <ion-input placeholder="Password" type="password" required [(ngModel)]="password"></ion-input> </ion-item> </ion-list> </ion-col> </ion-row> <ion-row> <ion-col> <div *ngIf="error" class="alert alert-danger">Invalid Credentials</div> <button ion-button class="submit-btn" full (click)="login()"> Login </button> </ion-col> </ion-row> </ion-content> |
Flow example to connect a user:
–| [from component] Dispatch action USER_CONNECT
–| [from user.effect.ts]
—-| Catch action ofType('USER_CONNECT')
—-| Do what you need to do (API call for ex)
—-| When the response comes back :
——| If success: Dispatch USER_CONNECT_SUCCESS
——| If error: Dispatch USER_CONNECT_ERROR
Finally we need to register all of our reducers and effects in our store. We’ll do this in our app.module.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 |
import { BrowserModule } from '@angula r/platform-browser'; import { ErrorHandler, NgModule } from '@angular/core'; import { IonicApp, IonicErrorHandler, IonicModule } from 'ionic-angular'; import { MyApp } from './app.component'; import { HomePage } from '../pages/home/home'; import { StatusBar } from '@ionic-native/status-bar'; import { SplashScreen } from '@ionic-native/splash-screen'; import { userReducer } from '../users/user.reducer' import { StoreModule, combineReducers } from '@ngrx/store'; import { UserEffects } from "../users/user.effects"; import { EffectsModule } from "@ngrx/effects"; @NgModule({ declarations: [ MyApp, HomePage, SearchPage, SettingsPage, ], imports: [ BrowserModule, HttpModule, FormsModule, UserModule, IonicModule.forRoot(MyApp), StoreModule.forRoot({userState: userReducer}), EffectsModule.forRoot([UserEffects]) ], bootstrap: [IonicApp], entryComponents: [ MyApp, LoginPage, SearchPage, SettingsPage ], providers: [ StatusBar, SplashScreen, {provide: ErrorHandler, useClass: IonicErrorHandler} ] }) export class AppModule {} |
The two most important lines here are:
StoreModule.forRoot({userState: userReducer})
EffectsModule.forRoot([UserEffects])
Remember, we have to add every reducer and effect here.
Now that we have everything wired up, how do we actually use this? We’ll write a login component that will accept a username and password. The first thing we’ll do is inject the NavController and our Store. Remember, our Store contains the state of our application, it is the source of truth when it comes to everything about our application.
You can always check my github for this ionic 3 redux application by following this link:
Github ionic 3 redux
Below you can find a video for this tutorial: