Redux in Ionic & Angular
TL;DR
- If you have a complex state to manage across your app, or an offline app with lots of interactions, or a state synced with a server data and a complicated UI to update accordingly, then Redux might already have solutions for many problems you will face.
- Redux works with a single immutable object tree as state and updates this state by dispatching actions and calling the right reducer to handle that action and transfer the state.
- You might have heard about it mostly in React communities but it works just as well with Ionic and Angular. If you want to know how, keep reading.
What is Redux? (and do you even need it?)
Imagine you have two components that need to communicate with each other. For example you have a notes page that passes down an array of notes to notes-list component and when the delete button in notes-list is clicked an event is emitted, notes array gets updated and the notes-list receives the updated list. Pretty straight forward state management using input and output properties. It doesn’t need anything more complicated obviously.
Now imagine another scenario in which the notes page shows the last 3 notes and to see the whole list of notes you need to navigate to notes-list page. Input and output can't help here and though workarounds like passing data and callback functions between pages could work but as it gets more complicated you don’t really want to do it. So you need a service to keep the state of the notes and both components will call the service’s methods and fetch the state from it. It's also good to have your UI automatically updated rather than fetching the state manually to update your component. The solution in an Ionic & Angular app would be using observables and subscribing to the stream of the state as it gets updated. There’s an example of such a scenario in Angular docs on components interaction.
So you still don’t need Redux. But when you need to define such services in different places in your app and each of these services handles a part of app's state and to make it worse imagine there’s a new requirement that works with two or more of these partial states and maybe some of the services need to use each other’s state and now you want to refactor some of them to work together and also there are some components interacting with each other aside from these services that you also need to take into account. It feels that things are getting slowly out of hand and you can’t track anymore what’s happening to the state of your app. How about saving the current state of your app to persist it for the next time the user wants to go on where they left off? What and where is that state to save? Maybe a good decision would be to have one service that handles the whole state and you just use it for everything to make your life easier. Well, one example of that solution is Redux. So if you think your app will reach that point of complexity then you might need a state management library like Redux.
Now that you know why and when you need Redux let’s see how Redux has solved the problems that we mentioned.
The three principles of Redux
A Redux application consists of three major parts or as they're called in official docs the three principles of Redux.
1. State
The whole state of the app is stored in one object tree.
- This makes it easy to persist the state locally or serialize it and deliver it to your app over the network and rehydrate your app from this persisted state.
- Remember how you lost the state of your app as it got complicated. In Redux this state is just an object tree that you can easily inspect.
- Every update to the state is clear, predictable and easy to inspect and debug and can also be reverted easily.
2. Actions
Actions are the only way to update the state.
- This means no views or network callbacks are allowed to mutate the state on their own without dispatching an action.
- Action is an object or a function that returns that object which defines an intent to update the state.
- The action explains in a minimal way what change should happen in the state.
- Actions are run in a centralized approach and strict order. This means you can follow the stream of actions, undo and redo them or even save them and run them again in the same order to produce the same result.
3. Reducers
Reducers are pure functions to actually transform the state.
- Reducers are pure functions that receive current state together with the dispatched action and they return the next state. They must be pure so that the return value is always the same for the same state and action. If the reducers are not pure and are affected by external variables or have side effects, the state transformation won’t be predictable and reproducible.
- The state in Redux is immutable. This means reducers don’t update the values in the current state directly. Instead they return a new state object completely.
- Being pure functions, reducers can be easily composed and they are easy to test and debug.
These principles need to be strictly followed to achieve the purpose of using Redux. This means you’re gonna have some boilerplate and indirection introduced in your source code that though it’s justifiable when handling complicated state transformation you might find it cumbersome when you just need to handle a minor change and you need to define the state, actions and reducers to achieve it. But that’s the trade off you make to keep the state of your app manageable.
Data flow in Redux
As mentioned in principles the only way to update the state is that the views dispatch actions, which are just plain objects describing the type of change and the payload that goes with the change. The assigned reducer receives the action and transfers the state to the new “state”. The view receives the new state and updates accordingly. And this cycle will go on always in the same direction. That’s why it’s called one way data flow. You can even go on and eliminate much of the direct component interactions and have the components subscribe to the state directly. But it’s good to distinguish between container (stateful) and presentational (stateful) components. Generally stateful components subscribe to the state and dispatch actions but stateless components just receive their data from a parent component and only render it. You can optimistically update the views and if needed revert the changes. The state management logic is in your actions and reducers and your components are decoupled from it. As your app grows, no matter how many views you add and how big is the state of the app the complexity of state management remains the same.
With all that said if you’re convinced to try Redux in your Angular app let’s see how it’s done.
Redux in Ionic & Angular
There are some libraries with Angular bindings for Redux which are written to make it possible to use Redux in Angular’s ecosystem.
Two popular libraries are @angular-redux/store and @ngrx/store.
From the two libraries the experience of using @angular-redux/store is closer to using Redux in react and you can follow the official Redux documentation for most of the development and apply some specific differences based on Angular ecosystem or libraries APIs. But you should also try @ngrx/store and go with the one that gives you a better development experience.
The first thing you need to do is to decide what your state will look like.
State shape in Redux
The Redux state is just an object with properties but there are some tips on how these properties are defined and what structure the object will have. It is recommended to keep the state tree normalized and avoid nesting the data too deep in the object and definitely avoid copying the same entity in different places. This means having entities at the root level of the object and make references to them when they are nested in another entity. Think of it as a relational database. This makes it easier to write reducers and update the state without going deep into the object tree to access a property or updating the same data in multiple places.
Let's go with an example app. It's a notes and reminders app. You write some notes and then you can optionally set reminders for each note. Here is an example of keeping the state of this app.
{
notes: {
byId: {
'ueku65oc0': {
id: 'ueku65oc0',
text: 'Hello Redux'
},
'mquaijmq2': {
id: 'mquaijmq2',
text: 'Using Redux in Ionic Angular'
},
'qy0r48x6d': {
id: 'qy0r48x6d',
text: 'That\'s awesome'
}
},
allIds: [
'ueku65oc0',
'mquaijmq2',
'qy0r48x6d'
]
},
reminders: [
{
id: 'hrxpfwbiw',
noteId: 'mquaijmq2',
date: '2018-07-09'
},
{
id: 'an8ivhm01',
noteId: 'qy0r48x6d',
date: '2018-07-10'
}
]
}
You might wonder what byId
and allIds
mean in notes state. We start off putting the notes in an array and refer to them by id in reminders array. To find a note in the notes array by id you need to iterate over all notes which is more code to write and less performant. So we create an object for references to ids and call it byId
and to have an array of notes we put all ids in allIds
property.
Setting up store
The store is the service that keeps the state of your app and provides methods to query or update the state. To configure the store you create the interface that defines the shape of the state and an initial state for your app, as well as the root reducer of your app which is the combination of all the reducers that each handle a part of the state.
Here is how we configure the store for the notes app.
/* app.module.ts */
...
import { NgReduxModule, NgRedux } from '@angular-redux/store';
import { rootReducer } from './reducers';
import { Actions } from './actions';
export interface AppState {
notes: Notes,
reminders: Reminder[]
}
export interface Notes {
byId: {[id: string]: Note},
allIds: string[]
}
export interface Note {
id: string;
text: string;
}
export interface Reminder {
id: string;
noteId: string;
date: string;
}
export const INITIAL_STATE = {
notes: {
byId: {},
allIds: []
},
reminders: []
}
@NgModule({
...
imports: [
...
NgReduxModule
],
...
providers: [
...
Actions
]
})
export class AppModule {
constructor(
private ngRedux: NgRedux<AppState>
) {
ngRedux.configureStore(
rootReducer,
INITIAL_STATE
)
}
}
And here are the reducers that will update the state based on the action they receive, e.g. ADD_NOTE
.
/* reducers.ts */
import { AppState, Notes, Reminder } from "./app.module";
import { Reducer } from "redux";
export const notesReducer: Reducer<Notes> = (state: Notes, action) => {
switch (action.type) {
case 'ADD_NOTE':
const id = uniqueId() // Some function that returns a unique id
return {
byId: {
...state.byId,
[id]: {
id,
text: action.payload
}
},
allIds: [...state.allIds, id]
}
default:
return state;
}
}
export const remindersReducer: Reducer<Reminder[]> = (state: Reminder[], action) => {
switch (action.type) {
case 'ADD_REMINDER':
const id = uniqueId();
return [
...state,
{
id,
noteId: action.payload.noteId,
date: action.payload.date
}
]
default:
return state;
}
}
export const rootReducer: Reducer<AppState> = (state: AppState, action) => ({
notes: notesReducer(state.notes, action),
reminders: remindersReducer(state.reminders, action)
})
Note that the root reducer just returns an object that maps state properties to their respective reducers. This is how you can define a reducer by combining other reducers.
And here are the actions that we're gonna dispatch. Note the @dispatch
decorator from angular-redux to define actions.
/* actions.ts */
import { Injectable } from "@Angular/core";
import { dispatch } from "@Angular-redux/store";
@Injectable()
export class Actions {
@dispatch()
addNote = (note: string) => ({
type: 'ADD_NOTE',
payload: note
})
@dispatch()
addReminder = (noteId: string, date: string) => ({
type: 'ADD_REMINDER',
payload: {noteId, date}
})
}
And finally the home component subscribes the store using @select
decorator of Angular-redux and dispatches the actions defined in Actions.ts
.
/* home.ts */
import { Component } from '@Angular/core';
import { select, NgRedux } from '@Angular-redux/store';
import { Observable } from 'rxjs/Observable';
import { Notes, Reminder, AppState, Action } from '../../app/app.module';
import { Actions } from '../../app/actions';
@Component({
selector: 'page-home',
templateUrl: 'home.html'
})
export class HomePage {
@select(['notes']) notes$: Observable<Notes>;
@select(['reminders']) reminders$: Observable<Reminder[]>;
constructor(private store: NgRedux<AppState>, private actions: Actions) {}
addNote(note: string) {
this.actions.addNote(note);
}
addReminder(noteId: string, date: string) {
this.actions.addReminder(noteId, date);
}
}
<!-- home.html -->
<ion-content padding>
<div *ngFor="let noteId of (notes$ | async).allIds">
{{(notes$ | async).byId[noteId].text}}
</div>
<div *ngFor="let reminder of (reminders$ | async)">
{{(notes$ | async).byId[reminder.noteId].text}} - {{reminder.date}}
</div>
<input type="text" placeholder="Add a note..." #input>
<button (click)="addNote(input.value)">Add note</button>
<select #note>
<option
*ngFor="let noteId of (notes$ | async).allIds"
[value]="(notes$ | async).byId[noteId].id">
{{(notes$ | async).byId[noteId].text}}
</option>
</select>
<input type="date" placeholder="Add a reminder..." #date>
<button (click)="addReminder(note.value, date.value)">Add reminder</button>
</ion-content>
You can directly use the observable that you defined using @select
decorator in the template using async
pipe. This way you don't need to worry about unsubscribing from observables.
Your Ionic Redux app is already working but there are some topics you need to know and read more about them.
An Enhancer: Redux DevTools
The first thing you need after setting up your store is to see if it's working properly and you've got what you wanted, and also to be able to inspect and debug all that happens in your Redux store. For that you need the Redux DevTools.
First install Redux DevTools browser extension for your browser and then update your store configuration to use Redux DevTools.
/* app.module.ts */
import { ..., DevToolsExtension } from '@Angular-redux/store';
...
export class AppModule {
constructor(
private ngRedux: NgRedux<AppState>,
private devTools: DevToolsExtension
) {
ngRedux.configureStore(
rootReducer,
INITIAL_STATE,
[],
[devTools.enhancer()]
)
}
}
DevTools enhancer is added to the enhancers array of configureStore method. Read more about enhancers here. Now when you run your app in browser you can go to the Redux tab of developer tools and inspect everything about your Redux store.
A Middleware: Redux Thunk
Soon you will come upon the situation that your actions need to be dispatched asynchronous for example in response to a network request. Or you might need to dispatch an action conditionally based on the current state of your app.
Redux store by itself only supports synchronous flows and to handle async flows you need a middleware like redux-thunk. A middleware is a function that receives actions and can transform them or dispatch them in a certain way. You can read about them more here.
To add the Redux thunk to your store config add it to and array which is the third parameter of configureStore method.
/* app.module.ts */
import thunk from 'redux-thunk';
...
export class AppModule {
constructor(
private ngRedux: NgRedux<AppState>,
private devTools: DevToolsExtension
) {
ngRedux.configureStore(
rootReducer,
INITIAL_STATE,
[thunk],
[devTools.enhancer()]
)
}
}
Conclusion
Those were the basics of managing the state of your app using Redux in an Ionic & Angular app. There is much more you can do with Redux and there are many creative ways to solve your problems when you're using Redux, and also lots of libraries that are written for specific use cases when using Redux. So it's best to start using it and learn more as you go.
For reading more about Redux in official docs go here:
redxu.js.org
And also check out the @Angular-redux/store on Github to learn about the API and more:
@Angular-redux/store
You might also find these useful:
redux-freeze
to prevent mutating your state.
redux-persist
to persist and rehydrate your app's state.