Left Arrow.Back

Global State in Next.js using XState

A wireframe of a sphere.

How to create global state in Next.js using XState.

Last TendedNov 2021
PlantedMay 2021
StatusSprout
TagsNext.js, React, XState

Overview

We'll create a finite-state machine to control our global state. We could use multiple machines but for simplicity, we'll stick to 1. The machine will be wrapped in a React context, making it accessible to all components. Lastly, we'll create 2 React components to access the global state. 1 optimized to remove unnecessary renders, the other not.

1. Create a Machine

Our 1st step will be to create a machine. It will have 3 states; state1, state2, state3 & 3 events; button1Clicked, button2Clicked, button3Clicked. Each event will change the machine's state:

  • button1Clicked state1
  • button2Clicked state2
  • button3Clicked state3
A statechart showing boxes for states with lines and arrows connecting them.

/myMachine.tsx

import { ActorRefFrom } from "xstate";
import { createModel } from "xstate/lib/model";
export const myModel = createModel(undefined, {
events: {
button1Clicked: () => ({}),
button2Clicked: () => ({}),
button3Clicked: () => ({})
}
});
export const myMachine = myModel.createMachine({
id: "myMachine",
initial: `state1`,
on: {
button1Clicked: `state1`,
button2Clicked: `state2`,
button3Clicked: `state3`
},
states: {
state1: {},
state2: {},
state3: {}
}
});
export type MyService = ActorRefFrom<typeof myMachine>;

2. Create a React Context

To make the machine accessible in any React component without the need for prop drilling, we will use React's Context API. We'll create a React context with a value of an object. Then we'll add a property myService to this object. Think of a service as a machine that has been started. So when we start our machine, we'll store in our context under the myService property. We are using an object here so in future, if we have more services we want to add to the global state, we can easily add another.

/contexts.tsx

import { createContext } from "react";
import { MyService } from "./myMachine";
interface MyContextType {
myService: MyService;
}
export const MyContext = createContext({} as MyContextType);

3. Start the Machine

In Next.js, the component exported from the pages/_app.tsx is persisted when the user navigates to different pages. This is where we want to start our machine. XState's React packages provides different 2 hooks to start a machine:

  • useMachine: starts a machine & returns the machine.
  • useInterpret: starts a machine & returns the service instance of the machine.

A service is a static reference to the running machine, which can be subscribed to. Being static, its value never changes, so it will not cause re-renders. The machine returned from the useMachine hook would cause a re-render every time the machine changes. Because the App component is a parent to all other components in a Next.js app, every time it re-renders, the whole app would re-render. Thus we want to keep re-renders of this component to a minimum, so we'll use the useInterpret hook.

Call MyContext's provider & pass in the service. Our machine is now running and its service can now be subscribed to within any component in our app.

/pages/_app.tsx

import * as React from 'react';
import { useInterpret } from '@xstate/react';
import { MyContext } from '../contexts';
import { myMachine } from '../myMachine';
import '../styles/globals.css';
export default function App({ Component, pageProps }) {
const myService = useInterpret(myMachine);
return (
<MyContext.Provider value={{ myService }}>
<Component {...pageProps} />
</MyContext.Provider>
);
}

4. Access Global State

Now lets create a React component & access the global state without prop drilling. Pass MyContext into the useContext hook to get access to the global state. The useActor hook can be passed myService to subscribe to it. The component can now read the machine's state & send events to it.

/ComponentA.tsx

import { useActor } from "@xstate/react";
import * as React from "react";
import { useContext } from "react";
import { MyContext } from "./contexts";
import { myModel } from "./myMachine";
export const ComponentA = () => {
const globalState = useContext(MyContext);
const [state, send] = useActor(globalState.myService);
return (
<section>
<h1>Component A</h1>
<output>
state: <strong>{state.value}</strong>
</output>
<button onClick={() => send(myModel.events.button1Clicked())}>
BUTTON 1
</button>
<button onClick={() => send(myModel.events.button2Clicked())}>
BUTTON 2
</button>
<button onClick={() => send(myModel.events.button3Clicked())}>
BUTTON 3
</button>
</section>
);
};

/pages/index.tsx

import * as React from 'react';
import { ComponentA } from '../ComponentA';
export default function Page() {
return (
<ComponentA />
);
}
Tachometer

5. Optimize

The component we just made has a performance issue. It will re-render when anything changes in our machine. If, for example, our component was only interested in knowing the machine was in state3 or not. It would be unnesscarily re-rendering when the machine changes from state1 to state2 & vice versa. To prevent this, we can use selectors. A selector is a function that returns specific information from the machine, such as is the machine in state3 or not?. Lets create this selector.

/myMachine.tsx

import { ActorRefFrom } from "xstate";
import { createModel } from "xstate/lib/model";
export const myModel = createModel(undefined, {
events: {
button1Clicked: () => ({}),
button2Clicked: () => ({}),
button3Clicked: () => ({})
}
});
export const myMachine = myModel.createMachine({
id: "myMachine",
initial: `state1`,
on: {
button1Clicked: `state1`,
button2Clicked: `state2`,
button3Clicked: `state3`
},
states: {
state1: {},
state2: {},
state3: {}
}
});
export type MyService = ActorRefFrom<typeof myMachine>;
export const selectIsState3 = (state) => state.matches("state3");

Now lets make a new component, ComponentB that won't have unnecessary re-renders. To do this, we'll need to replace the useActor hook with useSelector and pass in our selector.

/ComponentB.tsx

import { useSelector } from "@xstate/react";
import * as React from "react";
import { useContext } from "react";
import { MyContext } from "./contexts";
import { myModel, selectIsState3 } from "./myMachine";
let renderCount = 0;
export const ComponentB = () => {
const globalState = useContext(MyContext);
const { myService } = globalState;
const isState3 = useSelector(myService, selectIsState3);
renderCount++;
return (
<section>
<h1>Component B</h1>
<output>
isState3: <strong>{JSON.stringify(isState3)}</strong>
</output>
<output>
renderCount: <strong>{renderCount}</strong>
</output>
<button onClick={() => myService.send(myModel.events.button1Clicked())}>
BUTTON 1
</button>
</section>
);
};

/pages/_app.tsx

import * as React from 'react';
import { ComponentA } from '../ComponentA';
import { ComponentB } from '../ComponentB';
export default function Page() {
return (
<>
<ComponentA />
<ComponentB />
</>
);
}

6. Persist State

A common requirement is to persist & rehydrate global state (save it so when a user reopens your app, they land where they left off). The 3rd argument of useInterpret accepts a function. An observer that will fire whenever the state changes. It passes the state of the machine which we can store in local storage.

/pages/_app.tsx

import * as React from 'react';
import { useInterpret } from '@xstate/react';
import { MyContext } from '../contexts';
import { myMachine } from '../myMachine';
import '../styles/globals.css';
const STORAGE_KEY = "myPersistedState";
export default function App({ Component, pageProps }) {
const myService = useInterpret(
myMachine,
{},
(state) => localStorage.setItem(STORAGE_KEY, JSON.stringify(state))
);
return (
<MyContext.Provider value={{ myService }}>
<Component {...pageProps} />
</MyContext.Provider>
);
}

7. Rehydrate State

The 2nd argument of useInterpret is an options object. We can pass it our persisted state. The machine will now start in this state, rather than its initial state.

/pages/_app.tsx

import * as React from 'react';
import { useInterpret } from '@xstate/react';
import { MyContext } from '../contexts';
import { myMachine } from '../myMachine';
import '../styles/globals.css';
const STORAGE_KEY = "myPersistedState";
function rehydrateState() {
// this is required because Next.js will initially load this page on the server
if (typeof window === "undefined") {
return gameMachine.initialState;
}
return (
JSON.parse(localStorage.getItem(LOCAL_STORAGE_KEY)) ||
gameMachine.initialState
);
}
export default function App({ Component, pageProps }) {
const myService = useInterpret(
myMachine,
{ state: rehydrateState() },
(state) => localStorage.setItem(STORAGE_KEY, JSON.stringify(state))
);
return (
<MyContext.Provider value={{ myService }}>
<Component {...pageProps} />
</MyContext.Provider>
);
}
A computer screen with a blinking cursor.

Sandbox

We now have our optimized component. A sandbox has been created of our 2 components connected to a global state. Unfortunatley, I wasn't able to get the sandbox working with local storage, so persisting & rehydrating isn't included.

Open sandbox