⚔️ Code Conqueror

🗾 State Management in Next.js

Mar 08, 2020

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:

  • Is the state global or local, eg. would you need to maintain the state across client side route transitions
  • Is the state relatively simple or complex
  • How much boiler plate are you willing to write

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:

  • first the visitor will request a server side rendered page in most cases, possibly hitting a cache
  • then they will perform some actions and possibly transition to other pages
  • at various points the state that they generate by performing actions might need to be synced back to the server

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:

  • Take user input for their username / password
  • try to log the user in, and correctly handle error states (eg no server contact, timeout, bad password etc)
  • On success redirect them to their dashboard where we can see some of their user specific information (which they can only see if they are logged in)

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:

  • handle error because of an incorrect submission
  • handle error on client because of an invalid submission (eg username not email)
  • prevent more typing during submission
  • don't allow the same bad sumission multiple times in a row
  • only submit once (eg cannot keep pressing the button)

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.