Global State Management For React Apps

There is almost an infinite number of solutions for the problem most React applications face – state management. This post discusses a pragmatic and simple and yet powerful approach using the Context system.

Published on Mon, September 28, 2020

First of all: I don't think there is anything wrong with using solutions like Redux. I was so convinced that I took the idea and implemented a Redux store for .NET myself a few years ago. That implementation is still used in production for mobile apps that are under active development.

That being said, I today believe that most of the state an application contains can be dealt with locally at a component level. For example, it's not necessarily of interest to the whole app what a text field's content somewhere down in the hierarchy is. But when there is something that concerns the entire app, e.g., information about the currently logged-in user, then a way to hold and update that state on a global level is needed.

Requirements

My specific requirements for such a solution are:

  1. It should be easy to set up and easy to use.
  2. It should not spread out implementation details throughout the app.
  3. It should be easy to reason about state changes.
  4. It should be persistent and thus survive page reloads.

Well... the first three things sound a lot like MVU, don't they? And indeed, I figured out a pragmatic way that works quite well for me so far.

The Concept

MVU

The idea is as simple as probably known: There is a central application state (Model) that is being passed to the components anywhere in the hierarchy (View), which can, in turn, dispatch messages that are then processed by a global update function to create an updated state (Update).

As already shown before, the useReducer hook can be used to implement the general uni-directional data-flow quite easily for a React app. That hook will be one of the cornerstones of this implementation as well. The second one is the useContext hook, which gives access to React's Context API from within function components. Besides, the state will be read from and written to the browser's local storage – which is persisted across sessions.

So the general workflow will be as follows:

  1. Read the app state from the local storage or create a new one, if it doesn't exist.
  2. Provide that state through the Context API of React. So the state doesn't need to be "drilled down" through props but can instead be read only when required from components.
  3. Define messages as instructions to update the state and provide a dispatch function to components through the Context API.
  4. Have a global update function that takes in a message and the current app state and returns a transformed version of that state.
  5. Write that transformed state back to the local storage to persist it before it is passed down to all child components once again through the Context API.

The Implementation

Note: A full example can be found on GitHub.

It all starts by defining our AppState which, for this example, contains one property – the authenticated user's name, either undefined (signed out), or a string (signed in).

declare interface AppState {
  authenticatedUser: string | undefined;
}

Now we need some messages to define how that state can change:

declare type AppMsg =
    | { type: "SignIn", user: string }
    | { type: "SignOut" };

Our update function is now processing those messages:

function update(state: AppState, msg: AppMsg): AppState {
  switch (msg.type) {
    case "SignIn":
      return { ...state, authenticatedUser: msg.user };
    case "SignOut":
      return { ...state, authenticatedUser: undefined };
  }
}

We have a global state, messages that define what should happen to that state, and an update function that makes it happen.

To make that work, we now only need to wire it up at the root node. In our example, that's App.ts. Here, we first get the persisted state from the browser's local storage:

const [persistedState, setPersistedState] = useLocalStorage(
  "State",
  {} as AppState
);

We can now use that persisted state to initialize the useReducer hook, which will take care of our in-memory state. Note that we also want to persist any changes made to the state, so we have to intercept those calls of the update function. That is done by wrapping it into another function, updateAndPersistState(), which has the same signature and, therefore, can be passed to useReducer.

const updateAndPersistState = (state: AppState, msg: AppMsg) => {
  const updatedState = Update(state, msg);
  setPersistedState(updatedState);
  return updatedState;
};

const [state, dispatch] = useReducer(updateAndPersistState, persistedState);

We now have the two key elements we want to use within the application's components: The state and a dispatch function. To make them available to the components through the useContext hook, we will create two contexts: One for the state, one for the dispatch function.

Having those two split up into two contexts is useful as it allows components to only dispatch messages without being re-rendered.

Our contexts are held in their own module so we can use them anywhere:

import { Dispatch, createContext } from "react";

export const defaultAppState: AppState = {
  authenticatedUser: undefined,
};

export default {
  state: createContext<AppState>({} as AppState),
  dispatch: createContext<Dispatch<AppMsg>>(() => {}),
};

But first, we need to set them up at the root node:

<AppContext.dispatch.Provider value={dispatch}>
  <AppContext.state.Provider value={state}>
    {state.authenticatedUser !== undefined
      ? "Signed in as: " + state.authenticatedUser
      : "Not signed in."}
    <hr />
    <Child />
  </AppContext.state.Provider>
</AppContext.dispatch.Provider>

Last but not least, a simple child component example:

interface ButtonProps {
  dispatch: Dispatch<AppMsg>;
}

const SignIn = (props: ButtonProps) => (
  <button onClick={() => props.dispatch({ type: "SignIn", user: "Foo" })}>
    Sign in
  </button>
);
const SignOut = (props: ButtonProps) => (
  <button onClick={() => props.dispatch({ type: "SignOut" })}>Sign out</button>
);

export default function () {
  const state = useContext(AppContext.state);
  const dispatch = useContext(AppContext.dispatch);

  return (
    <div>
      {state.authenticatedUser ? (
        <SignOut dispatch={dispatch} />
      ) : (
        <SignIn dispatch={dispatch} />
      )}
    </div>
  );
}

First, we read the current state and check if authenticatedUser is being set. If so, we show the sign-out button. If its value is undefined instead, we know there is no user, so we offer the sign-in button instead.

Both buttons dispatch a message in their on-click handler accordingly. Those messages are sent to the update function, which returns an updated version of the state, causing a new render cycle for all involved components.

Conclusion

Let's see if all requirements were met:

  1. While all of that might look a bit scary if you're not familiar with React or MVU, I am pretty sure it has a very reasonable "cognitive load" once you get to know it.
  2. The state can only be changed at a single place. That's the update function. All "intents" to change something are expressed very explicitly through messages, and implementation details are not sprinkled across the codebase.
  3. We "hook in" reading from and writing to local storage at the top level, which makes that also relatively simple to reason about.

That's it!

What do you think?
Drop me a line and let me know!