State Management with Redux & StencilJS: Loading Data
Over the past few weeks we have been covering various aspects of state management in Ionic applications - both with Angular (using NgRx) and StencilJS (using Redux).
So far, we have only covered the basics concepts of Ionic + StencilJS + Redux, but this tutorial is going to jump straight into a realistic example of using Redux for an Ionic/StencilJS application. We will be using Redux to store data loaded from a local JSON file using the Fetch API (although you could just as easily make the request to a real server). With the data loaded into the Redux store, we will be able to access the state/data we need from the components in our application.
We will also handle loading and error/failure states so that our application is able to respond accordingly to these situations (e.g. we might want to show a loading overlay whilst the data is in the process of being loaded, or we might want to display an error message to the user if the data fails to load).
Although we will be using the Ionic PWA Toolkit in this example, these concepts will apply generically to any StencilJS application.
Outline
Source codeBefore We Get Started
If you don't already have an understanding of the basic concepts of Redux, I would highly recommend that you watch this video first: What is Redux?.
I'll preface this tutorial by saying that you don't necessarily need to use Redux in your StencilJS applications. You might not find the complexity worthwhile for simple applications and may find it easier to manage data/state through services.
However, although there is a bit more work involved in setting up and learning a state management solution like Redux, it does provide powerful benefits.
The Basic Concept
If you're reading this you should already have somewhat of an understanding behind the basic idea of how actions and reducers work (or at least what their role is). An action describes some intent (e.g. SET_FILTER
), and a reducer takes in the current state and an action and produces a new state that reflects the result of that action.
Since I have already given you the context that we are going to be loading in some data from a JSON file, you might expect that we would just need to create a single action like:
LOAD_DATA
However, we will actually be creating three separate actions to handle this process:
LOAD_DATA_BEGIN
LOAD_DATA_SUCCESS
LOAD_DATA_FAILURE
First, we will dispatch a LOAD_DATA_BEGIN
action. Depending on the success of our fetch
request, we will then either dispatch a LOAD_DATA_SUCCESS
action or a LOAD_DATA_FAILURE
action. We do this because we don't just immediately get the data when we load it. First, there is a period of time whilst the data is loading (e.g. a request to some API is in progress) in which we don't have access to the data - we might want to display something specific in our application during this time. Whilst the data will likely eventually load in most of the time, it is possible that an error could occur (e.g. there was a bug in your code or the server you are requesting data from is down). In that case, we probably also want to make sure that our application handles that situation appropriately.
Using these three actions will allow us to more accurately describe the state of our application, and result in a more bullet-proof solution that allows us to handle multiple different scenarios that can arise from loading data.
Installing Redux
Once you have a StencilJS project created (it doesn't matter whether you choose an Ionic PWA or not, that is just what I will be using in the example code) you will need to install the following packages:
npm install redux
This will, probably unsurprisingly, install Redux itself.
npm install redux-thunk
This is additional middleware for Redux, which is basically like a plugin that adds extra functionality. Redux Thunk will allow us to use an asynchronous function as an action (which will allow us to create an action that launches our asynchronous fetch
request).
npm install redux-logger
This is some more middleware that will provide us with some nice debug log messages - this is extremely useful as it allows us to easily see what actions are being triggered and what the resulting state looks like.
npm install @stencil/redux
Finally, we have the StencilJS package for Redux which basically just provides functionality for integrating Redux into our StencilJS components.
Setting up Redux in StencilJS
Before we get into the specifics of implementing our data loading solution, let's first just get a basic implementation of Redux set up in our StencilJS project. We are going to need to create a few files/folders and add a bit of configuration for our Redux store.
Create a file at src/store/index.ts and add the following:
import { createStore, applyMiddleware } from 'redux';
import rootReducer from '../reducers/index';
import thunk from 'redux-thunk';
import logger from 'redux-logger';
const configureStore = (preloadedState: any) =>
createStore(rootReducer, preloadedState, applyMiddleware(thunk, logger));
export { configureStore };
When using Redux, we have a single store that stores all of the state for our application. We use this file to configure that store, which involves supplying it with the reducers we will create, any initial state that we want, and any middleware that we want to use. This will likely look pretty similar for most implementations, the only "special" thing we are really doing here is setting up our thunk
and logger
middleware. The reducers are being pulled in from a different file that we will create soon.
Modify your root component at src/components/app-root/app-root.tsx to configure the store:
import { Component, h } from '@stencil/core';
import { store } from '@stencil/redux';
import { configureStore } from '../../store/index';
@Component({
tag: 'app-root',
styleUrl: 'app-root.css',
})
export class AppRoot {
componentWillLoad() {
store.setStore(configureStore({}));
}
render() {
return (
<ion-app>
<ion-router useHash={false}>
<ion-route url="/" component="app-home" />
<ion-route url="/profile/:name" component="app-profile" />
</ion-router>
<ion-nav />
</ion-app>
);
}
}
We also need to do a little configuration for the store in our root component for the application. We import store
from the StencilJS package for redux. Then, inside of the componentWillLoad
lifecycle hook, we make a call to the setStore
method using the configureStore
function we just set up in the previous file.
Create a file at src/reducers/index.ts and add the following:
import dataReducer from './data';
import { combineReducers } from 'redux';
const rootReducer = (combineReducers as any)({
dataReducer,
});
export default rootReducer;
Now we need to set up our reducers (which we referenced in our store file). This index.ts
file will combine all of the reducers we create for our application, but in this case, we are just going to have a single dataReducer
.
We are going to set up a simple version of our dataReducer
that is being imported now, but we will focus on the actual implementation of the reducer later.
Create a file at src/reducers/data.ts and add the following:
import { Actions, ActionTypes } from '../actions/index';
interface DataState {
items: string[];
loading: boolean;
error: any;
}
const getInitialState = () => {
return {
items: [],
loading: false,
error: null,
};
};
const dataReducer = (
state: DataState = getInitialState(),
action: ActionTypes
) => {
switch (
action.type
// handle different actions
) {
}
return state;
};
export default dataReducer;
This is the basic outline of our reducer, but we have removed the implementation details - this is what most reducers will look like to begin with. We set up a DataState
interface to represent the type of data/state we want to hold for this reducer - an array of items
that will be simple strings, a loading state that will be true
when the data is in the process of being loaded, and an error
for holding any errors that occur.
We then define a getInitialState
function that returns the initial state we want to use - most of the time this is just going to be empty/blank/null values.
Then we have the dataReducer
itself, which takes in the state
and an action
. It will then switch between the various possible actions (in our case this will be LOAD_DATA_BEGIN
, LOAD_DATA_SUCCESS
, and LOAD_DATA_FAILURE
). The responsibility of the reducer is to return some new state based on the current state and the action that has been supplied. Again, if you aren't already reasonably familiar with these general concepts I would recommend watching my Redux video first.
Next, we need to set up our actions that will be passed into the reducer.
Create a file at src/actions/data.ts and add the following:
import { Actions } from '../actions/index';
export interface LoadDataBeginAction {
type: Actions.LOAD_DATA_BEGIN;
}
export const loadDataBegin = () => async (dispatch, _getState) => {
return dispatch({
type: Actions.LOAD_DATA_BEGIN,
});
};
export interface LoadDataSuccessAction {
type: Actions.LOAD_DATA_SUCCESS;
payload: any;
}
export const loadDataSuccess = (data) => async (dispatch, _getState) => {
return dispatch({
type: Actions.LOAD_DATA_SUCCESS,
payload: { data },
});
};
export interface LoadDataFailureAction {
type: Actions.LOAD_DATA_FAILURE;
payload: any;
}
export const loadDataFailure = (error) => async (dispatch, _getState) => {
return dispatch({
type: Actions.LOAD_DATA_FAILURE,
payload: { error },
});
};
First, we import Actions
from the index
file for the actions that we will create in just a moment - in this file we will provide some consistent names for our actions so that if we accidentally make a typo when typing out LOAD_DATA_BEGIN
or any of the other actions (I seem to have a habit of typing BEING
instead of BEGIN
), we are immediately going to see the error if we are using something like Visual Studio Code (because the property won't exist on Actions
, as opposed to just typing out a string value where the code editor wouldn't know that it was a mistake).
Then we create our three actions inside of this file, and for each action, we create an interface
to describe its structure. Each action requires a type
that will describe what the action does (e.g. LOAD_DATA_BEGIN
) and it can also optionally have a payload
which can contain additional data. The payload
in our case might be the items
we want to load in for LOAD_DATA_SUCCESS
or the error
that occurred for LOAD_DATA_FAILURE
. No payload is required for LOAD_DATA_BEGIN
because it is just launching the process for loading the data.
Notice that the actions with a payload have that payload passed into the function:
export const loadDataSuccess = data => async (dispatch, _getState) => {
export const loadDataFailure = error => async (dispatch, _getState) => {
whereas the action with no payload does not pass in any parameters:
export const loadDataBegin = () => async (dispatch, _getState) => {
Now we just need to create that actions index file.
Create a file at src/actions/index.ts and add the following:
import {
LoadDataBeginAction,
LoadDataSuccessAction,
LoadDataFailureAction,
} from './data';
// Keep this type updated with each known action
export type ActionTypes =
| LoadDataBeginAction
| LoadDataSuccessAction
| LoadDataFailureAction;
export enum Actions {
LOAD_DATA_BEGIN = 'LOAD_DATA_BEGIN',
LOAD_DATA_SUCCESS = 'LOAD_DATA_SUCCESS',
LOAD_DATA_FAILURE = 'LOAD_DATA_FAILURE',
}
This file doesn't actually do much, its main purpose is to list the various available actions so that they can be imported and used elsewhere. Again, if you are using something like Visual Studio Code, this would allow you to just start typing:
Actions.
In another file where you are importing Actions
and it will immediately pop up with a list of all of the available actions. This saves you time looking them up all the time, and it also prevents mistakes through typos.
Create Test Data
We've got the basic structure for our Redux store set up now, but before we implement the rest of it we are going to need some test data. As I mentioned, we are going to make a GET request with fetch
, but we are just going to be using a local JSON file as the data source. You can modify this data with your own file, or you could make a real HTTP request to some API if you prefer.
Create a file at src/assets/test-data.json and add the following:
{
"items": [
"car",
"bike",
"shoe",
"grape",
"phone",
"bread",
"valyrian steel",
"hat",
"watch"
]
}
Implementing the Actions and Reducer
Now we get to the bit that actually makes our actions/reducer do something interesting. Let's consider how we want this to work.
- When we dispatch a
LOAD_DATA_BEGIN
action we want to trigger thefetch
request and set theloading
state totrue
. The following two actions should be dispatched automatically depending on whether or not the data loading succeeds. - When the
LOAD_DATA_SUCCESS
action is dispatched, we want to set theloading
state tofalse
and we want to set theitems
state to the data payload that has been loaded in - When the
LOAD_DATA_FAILURE
action is dispatched, we want to set theloading
state tofalse
and we want to set theerror
state to whatever error occurred
Let's begin by making our dataReducer
reflect this intent.
Modify src/reducers/data.ts to reflect the following:
const dataReducer = (
state: DataState = getInitialState(),
action: ActionTypes
) => {
switch (action.type) {
case Actions.LOAD_DATA_BEGIN: {
return {
...state,
loading: true,
error: null,
};
}
case Actions.LOAD_DATA_SUCCESS: {
return {
...state,
loading: false,
items: action.payload.data,
};
}
case Actions.LOAD_DATA_FAILURE: {
return {
...state,
loading: false,
error: action.payload.error,
};
}
default:
return state;
}
};
Remember that our reducer does not modify the existing state, it creates a new state. This is why we would return something like this:
return {
...state,
};
It uses the spread operator to take all of the properties out of the existing state, and add them to the new state object that we are returning. In this sense, we are creating a new state that exactly reflects the previous state not just returning the existing state. However, we don't want the state to be unchanged, that is why we do this:
return {
...state,
loading: false,
items: action.payload.data,
};
First, we reconstruct the old state inside of our new state, but then we specifically overwrite the loading
and items
properties. Therefore, we aren't modifying the existing state (this is not allowed in Redux), we are supplying a new different state.
Each of our actions supplies new state properties to reflect what we wanted to achieve with those actions, using the payload
that is passed into it if necessary. But where does that payload come from? Where is the fetch
request happening? So far, we have done a whole lot of describing and not much doing. Let's finally add the key ingredient.
Remember before how we set up the redux-thunk
middleware? This was so that we could use an asynchronous function as an action. Let's create that function now.
Modify src/actions/data.ts to reflect the following:
import { Actions } from '../actions/index';
interface DataResponse {
items: string[];
}
export function loadData() {
return async (dispatch) => {
// Trigger the LOAD_DATA_BEGIN action
dispatch(loadDataBegin());
try {
let response = await fetch('/assets/test-data.json');
handleErrors(response);
let json: DataResponse = await response.json();
// Trigger the LOAD_DATA_SUCCESS action
dispatch(loadDataSuccess(json.items));
return json.items;
} catch (error) {
// Trigger the LOAD_DATA_FAILURE action
dispatch(loadDataFailure(error));
}
};
}
function handleErrors(response) {
if (!response.ok) {
throw new Error(response.statusText);
}
return response;
}
// ACTIONS
export interface LoadDataBeginAction {
type: Actions.LOAD_DATA_BEGIN;
}
export const loadDataBegin = () => async (dispatch, _getState) => {
return dispatch({
type: Actions.LOAD_DATA_BEGIN,
});
};
export interface LoadDataSuccessAction {
type: Actions.LOAD_DATA_SUCCESS;
payload: any;
}
export const loadDataSuccess = (data) => async (dispatch, _getState) => {
return dispatch({
type: Actions.LOAD_DATA_SUCCESS,
payload: { data },
});
};
export interface LoadDataFailureAction {
type: Actions.LOAD_DATA_FAILURE;
payload: any;
}
export const loadDataFailure = (error) => async (dispatch, _getState) => {
return dispatch({
type: Actions.LOAD_DATA_FAILURE,
payload: { error },
});
};
The key change in this file is this:
export function loadData() {
return async (dispatch) => {
// Trigger the LOAD_DATA_BEGIN action
dispatch(loadDataBegin());
try {
let response = await fetch('/assets/test-data.json');
handleErrors(response);
let json: DataResponse = await response.json();
// Trigger the LOAD_DATA_SUCCESS action
dispatch(loadDataSuccess(json.items));
return json.items;
} catch (error) {
// Trigger the LOAD_DATA_FAILURE action
dispatch(loadDataFailure(error));
}
};
}
function handleErrors(response) {
if (!response.ok) {
throw new Error(response.statusText);
}
return response;
}
We are creating a loadData()
function that, when called, will dispatch the LOAD_DATA_BEGIN
action using the action we have already created for it further below in the file. This will then make that fetch
request and if it is successful it will dispatch the LOAD_DATA_SUCCESS
action whilst also passing the items
that were loaded into it. If an error occurs during this process, which will be caught by our try/catch
block, then the LOAD_DATA_FAILURE
action will be dispatched instead.
Now all we need to do is call that loadData()
method to kick off this whole process.
Consuming State in Your Components
We're almost done! But, there is one more thing we need to take care of. We have everything set up, but we still need to use it somewhere. We're going to walk through an example of triggering our load and consuming state from a Redux store in the home page of a StencilJS/Ionic application.
Modify src/components/app-home/app-home.tsx to reflect the following:
import { Component, State, h } from '@stencil/core';
import { store } from '@stencil/redux';
import { loadData } from '../../actions/data';
@Component({
tag: 'app-home',
styleUrl: 'app-home.css',
})
export class AppHome {
@State() items: any;
@State() loading: boolean;
@State() error: any;
loadData: (...args: any) => any;
componentWillLoad() {
store.mapStateToProps(this, (state) => {
const {
dataReducer: { items, loading, error },
} = state;
return {
items,
loading,
error,
};
});
store.mapDispatchToProps(this, {
loadData,
});
this.loadData();
}
render() {
return [
<ion-header>
<ion-toolbar color="danger">
<ion-title>Ionic + StencilJS + Redux</ion-title>
</ion-toolbar>
</ion-header>,
<ion-content>
<ion-list lines="none">
{this.items.map((item) => (
<ion-item>
<ion-label>{item}</ion-label>
</ion-item>
))}
</ion-list>
</ion-content>,
];
}
}
We have set up three member variables with the StencilJS @State()
decorator (which causes our template to re-render to reflect changes each time any of these values change). We then use mapStateToProps
from the store to map the state from the store onto those member variables, which will make them accessible to us in the component. There is a lot of weird/fancy destructuring assignment syntax going on here, but it's basically just a neater way to write this.items = state.dataReducer.items
for each piece of state that we want to set up. You can learn more about destructuring assignments here if you like: Understanding { Destructuring } = Assignment.
With that done, we can just simply access this.items
, this.loading
, and this.error
in our component and it will reflect whatever values they are supposed to contain. We do still need to call that loadData()
function somewhere, though.
To do that, we set up a member variable called loadData
. We then use the mapDispatchToProps
method from the store to assign the loadData
method imported from our actions file to the member variable. Then we just call the this.loadData()
method to automatically trigger the load process as soon as the component has loaded.
If you check out the console logs when running your application you should now see the various actions being triggered:
This is due to our use of redux-logger
which is really cool because it allows you to see the previous state and then how the current action changed the new state.
Just for a bit of fun, let's purposefully make our load request fail. If we modify this call:
let response = await fetch('/assets/test-data.json');
to this:
let response = await fetch('/wrong/path/test-data.json');
It is going to fail. Let's see what happens to our Redux store in that scenario:
You can see that the LOAD_DATA_FAILURE
action is triggered and the appropriate error is supplied. We haven't implemented it in this example, but we could then easily make use of this new state in our component to display an appropriate error message to the user since it would be available through this.error
.
Summary
Rather than having to manually manage state and write conditions for various things that might happen, this whole process is now kind of "out of sight, out of mind". It requires a little more legwork initially, but now we can just trigger a single loadData()
function, and our component will reflect an appropriate state - no matter if the data loading succeeds or fails.