A state management pattern for Ionic React with React Hooks

Max Lynch - Jul 8 '19 - - Dev Community

How to manage state in your app can often be the biggest and most impactful architectural decision you make.

Unfortunately, there is no standard practice for state management. Developers have to choose between a wide variety of techniques and libraries (many of them 3rd party), including Redux, MobX, state “tunneling,” singleton state services, or just hacking it together. Some of these solutions are optimized for large apps, and some for small ones.

With React Hooks, however, we finally have a state management technique that is both native to the framework, and a good fit for a huge swathe of apps (except, perhaps, very large ones).

If you aren’t familiar with Hooks in React, go read our introduction to Using React Hooks in Ionic React, it offers a primer on the new APIs and how to build basic apps with them. We will gloss over that in this post.

Let’s jump in.

State Management With React Hooks

React now ships with a number of hooks, including two that we can use to build a powerful state management system right into our app: useContext and useReducer.

At the risk of oversimplifying, a simple state management system has a few desirable properties: 1) it’s global, so state is managed in one place instead of all over your app and 2) individual components don’t modify or mutate state themselves, but rather emit “actions” to the state management system which can then mutate the state, causing the tree of components to update if necessary.

If you recognize redux in the above, congratulations! That’s effectively what we’re going to build with React Hooks.

The Pattern

Okay, let’s get to the pattern. We’re going to build our state management system in one file called State.jsx (or tsx if using TypeScript):

import React, { createContext, useReducer } from "react";

let AppContext = createContext();

const initialState = {
  count: 0
}

let reducer = (state, action) => {
  switch(action.type) {
    case "setCount": {
      return { ...state, count: action.user }
    }
  }
  return state;
};

function AppContextProvider(props) {
  const fullInitialState = {
    ...initialState,
  }

  let [state, dispatch] = useReducer(reducer, fullInitialState);
  let value = { state, dispatch };

  return (
    <AppContext.Provider value={value}>{props.children}</AppContext.Provider>
  );
}

let AppContextConsumer = AppContext.Consumer;

export { AppContext, AppContextProvider, AppContextConsumer };

In this file, we set up our Context, which our child components will access with the useContext hook. When they do this, they will have access to two things that we’ve set as the value on our AppContext.Provider: state and our dispatch function. Which are returned from calling the useReducer hook. state is the current global state, which can be used for rendering/etc., and dispatch allows components to emit actions that our reducer function will process to turn into a new state object.

The reducer function takes two arguments: the current state, and the action that was performed. It then returns a new state object that contains any differences after processing the action.

Let’s take a look at an example component to see how we’d use this:

import React, { useContext } from 'react';
import { IonButton } from '@ionic/react';
import { AppContext } from '../State';

export const MyComponent = () => {
  const { state, dispatch } = useContext(AppContext);

  return (
    <div>
      <IonButton onClick={() => dispatch({
        type: 'setCount',
        count: state.count + 1
      })}>
        Add to Order
      </IonButton>
      <h2>You have {state.count} in your cart</h2>
    </div>
  )
}

That’s pretty much it for the basic state management pattern! Our components access state from the Context and dispatch actions to the reducer, which in turn updates the global state, which causes components to re-render. Pretty simple!

There are a few other things we can add to our state management system to make it even more powerful, though.

Logging

A common need for state management is logging actions for debugging purposes.

Logging can be done very simply by wrapping the reducer function with a simple logging function and using that function as the argument to useReducer instead of the original reducer function:

const logger = (reducer) => {
  const reducerWithLogger = (state, action) => {
    console.log("%cPrevious State:", "color: #9E9E9E; font-weight: 700;", state);
    console.log("%cAction:", "color: #00A7F7; font-weight: 700;", action);
    console.log("%cNext State:", "color: #47B04B; font-weight: 700;", reducer(state,action));
    return reducer(state,action);
  };
  return reducerWithLogger;
}

const loggerReducer = logger(reducer);

function AppContextProvider(props) {
  // ...
  let [state, dispatch] = useReducer(loggerReducer, fullInitialState)
  // ...
}

Resulting in helpful log info like this:

Persistence

Another common need for a state management system is persistence, either of the entire state or a subset of it.

We can achieve this in a simple way using localStorage and adding a few lines of code to our state system:

const initialState = {...}

const persistedState = JSON.parse(window.localStorage['persistedState']);

function AppContextProvider(props) {
  const fullInitialState = {
    ...initialState,
    ...persistedState
  }
  // ...
}

This first setups up the initial state to contain any data we’ve persisted in persistedState.

Then, to keep the persisted data up to date when state changes, we can use useEffect which will run every time our state is updated. In this example we’re persisting a new state.user field which might contain a user’s session token:

function AppContextProvider(props) {
  const fullInitialState = {
    ...initialState,
    ...persistedState
  }

  let [state, dispatch] = useReducer(loggerReducer, fullInitialState);

  useEffect(() => {
    // Persist any state we want to
    window.localStorage['persistedState'] = JSON.stringify({
      user: state.user
    });
  }, [state]);
  // ...
}

This will let us keep specific fields in our state persisted if they change, and load them back when the app starts up again. In that sense, the persistence is reactive and we don’t have to think about it. Note: using localStorage is bad for anything that needs to live for a long time as the browser/OS may clean it up. It’s perfectly fine for temporary data, however.

Conclusion

There you have it, a simple pattern for state management in Ionic React with React hooks. There are simpler state management patterns, to be sure, but I feel this strikes a nice balance between being simple enough for basic apps, and complex enough for decent sized ones, too. If I were going to build a Very Serious app, I’d probably still use Redux to benefit from the various libraries and techniques available there.

I like this pattern so much I’ve used it now on three different Ionic React apps. Much like a sourdough starter, I copy this state management system for each new app I build.

What do you think? Do you like this pattern? Could something be improved or tweaked? Let us know in the comments!

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .