State Management in Next.js

2020-03-08

Managing state in Next.js or any React based framework comes with it's challenges, but the route that gives the most power to boiler plate ratio is using Xstate to build state machines at both the local and global levels

I've been through so many different ways of managing application state in Next.js over the years, and each approach comes with it's benefits and drawbacks, but the decision boils down to three main considerations:

boiler plate complexity

State managements solution compared on complexity of state manageable vs how much boiler plate code is required

A note before we begin. We will not consider GraphQL here. Generally, when using GraphQL you will keep a lot of your application state on the server and sync to the client very regularly. Certainly if you are using GraphQL you can still benefit from these state management solutions, but Apollo will handle a lot more of the heavy lifting.

State management is a tradeoff

Managing state is a tradeoff between how much boiler plate code you write and how complex a state you can effectively manage without introducing bugs into the software. In the React ecosystem there have been a few popular state management paradigms over the years, among those using the built-in React state management and Redux as the most popular.

The recommendation is typically that one should not reach for Redux "too early" when building an application because of the amount of boiler plate overhead it introduces vs the built-in counterparts. This had left many, including myself, very unsatisfied because the built in solutions quickly can introduce a mess of state that is difficult to manage without introducing a lot of bugs, but Redux felt like "overkill" when working on relatively simple state management.

Then came Xstate, which I think is the way to go from now on. If you are unfamiliar, Xstate is a library in javascript for managing application state using finite state machines. It adds some minimal boiler plate to managing state, but actually each piece of boiler plate feels like it adds extra safety to your application, something you "should have done anyways" when using useReducer. Therefore I would recommend that essentially all client state in React based applications be managed via Xstate.

State management considerations for Next.js

With that setup out of the way, let's consider the particular quirks of Next.js and how state comes into play. It's first worth considering what the lifecycle of a visitor is:

The trickiest part is keeping state when transitioning between pages on the client. We'll go into two strategies for dealing with that, the first being to make your state "global" to the whole application by storing it in _app.js and the second using layout components that are shared between two pages. We'll review a simple application that uses both of these approaches.

Sharing state via global _app.js

One can store application level global state via Next.js's built in _app.js component which is rendered at the top of the component tree. This is a great choice for storing things like the credentials of logged in users etc. which need to be available and consistent on every route in the application. For our simple example we'll consider a simple login homepage and a dashboard that's only available if users are logged in. Our simple app will need to do the following:

We've put together a simple repository of the app using Xstate to manage all the state, but the highlights are, a custom _app.js that renders the provider:

// pages/_app.js

import { GlobalContextProvider } from "../context/global";
import App from "next/app";

class MyApp extends App {
  render() {
    const { Component, pageProps } = this.props;
    return (
      <GlobalContextProvider>
        <Component {...pageProps} />
      </GlobalContextProvider>
    );
  }
}

export default MyApp;

The global state (user logged in or not) as a React Context object:

// context/global.js

import React from "react";
import { useMachine } from "@xstate/react";
import { Machine, assign } from "xstate";

export const GlobalStateContext = React.createContext();
export const GlobalDispatchContext = React.createContext();

const globalMachine = Machine(
  {
    id: "global",
    initial: "loggedOut",
    context: {
      userData: null,
    },
    states: {
      loggedIn: {
        on: {
          LOGOUT: {
            target: "loggedOut",
            actions: "clearUserData",
          },
        },
      },
      loggedOut: {
        on: {
          LOGIN: {
            target: "loggedIn",
            actions: "setUserData",
          },
        },
      },
    },
  },
  {
    actions: {
      clearUserData: assign({
        userData: (_ctx, _evt) => null,
      }),
      setUserData: assign({
        userData: (_ctx, evt) => evt.value,
      }),
    },
  }
);

export const GlobalContextProvider = ({ children }) => {
  const [current, send] = useMachine(globalMachine);

  return (
    <GlobalStateContext.Provider value={current}>
      <GlobalDispatchContext.Provider value={send}>
        {children}
      </GlobalDispatchContext.Provider>
    </GlobalStateContext.Provider>
  );
};

You can see for the context we have two states loggedIn and loggedOut where when we log in we set some global user data and when we log out we clear it. And the actual login form looks like:

// pages/index.js

import React from "react";
import Router from "next/router";
import { Machine, assign } from "xstate";
import { useMachine } from "@xstate/react";

import { GlobalStateContext, GlobalDispatchContext } from "../context/global";

function sleep(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

const loginMachine = Machine({
  id: "login",
  initial: "editing",
  context: {
    username: "",
    password: "",
    error: null,
  },
  states: {
    editing: {
      on: {
        CHANGE_USERNAME: {
          actions: "changeUsername",
        },
        CHANGE_PASSWORD: {
          actions: "changePassword",
        },
        SUBMIT: "submitting",
      },
    },
    submitting: {
      invoke: {
        src: "submit",
        onDone: {
          target: "success",
          actions: "setUserData",
        },
        onError: {
          target: "failure",
          actions: "setError",
        },
      },
    },
    success: {
      type: "final",
    },
    failure: {
      on: {
        CHANGE_USERNAME: {
          target: "editing",
          actions: ["changeUsername", "clearError"],
        },
        CHANGE_PASSWORD: {
          target: "editing",
          actions: ["changePassword", "clearError"],
        },
      },
    },
  },
});

const HomePage = () => {
  const globalState = React.useContext(GlobalStateContext);
  const globalDispatch = React.useContext(GlobalDispatchContext);
  const [current, send] = useMachine(loginMachine, {
    actions: {
      changeUsername: assign({
        username: (_ctx, evt) => evt.value,
      }),
      changePassword: assign({
        password: (_ctx, evt) => evt.value,
      }),
      setUserData: (_ctx, evt) => {
        globalDispatch({ type: "LOGIN", value: evt.data });
        Router.push("/dashboard");
      },
      setError: assign({
        error: (_ctx, evt) => evt.data,
      }),
      clearError: assign({
        error: (_ctx, _evt) => null,
      }),
    },
    services: {
      submit: (_ctx, _evt) =>
        new Promise(async (resolve, reject) => {
          await sleep(2000);
          const rand = Math.random();
          if (rand < 0.5) {
            reject("failed to log in");
          } else {
            resolve("user secret data");
          }
        }),
    },
  });

  const handleUsernameChange = (e) => {
    send({ type: "CHANGE_USERNAME", value: e.currentTarget.value });
  };

  const handlePasswordChange = (e) => {
    send({ type: "CHANGE_PASSWORD", value: e.currentTarget.value });
  };

  const handleSubmit = (e) => {
    e.preventDefault();
    send({ type: "SUBMIT" });
  };

  return (
    <div>
      Signin:
      <form onSubmit={handleSubmit}>
        <input
          value={current.context.username}
          onChange={handleUsernameChange}
        />
        <input
          value={current.context.password}
          onChange={handlePasswordChange}
        />
        <button type="submit">Submit</button>
      </form>
      <div>{current.context.error}</div>
      {current.matches("submitting") && <div>signing in...</div>}
    </div>
  );
};

export default HomePage;

As you can see we use Xstate in two places, firstly for the machine that takes user input and manages the local state of submitting the form, getting errors etc. And the second at the global app level. The machine used for taking user input looks like it might be a bit more complicated than eg a combination of useEffect and useState to manage the username and password state, but when you consider all the details that you need to get right:

You can see that even a simple signin form has enough complexity to warrant care when managing the state, which is where XState comes in.

Lastly, the other page will render the global state eg if you are logged in you can see your user data otherwise there is nothing:

// pages/dashboard.js

import React from "react";
import { GlobalStateContext } from "../context/global";

const DashboardPage = () => {
  const globalState = React.useContext(GlobalStateContext);

  return <div>{globalState.context.userData}</div>;
};

export default DashboardPage;

Other than that next.js acts very smiliarly to any other react framework, just that you have to remember to hoist any global state providers up to the top level of the _app.js component.