Back

Global State in Next.js using XState

A wireframe of a sphere.

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

Last TendedAug 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 in the app. This is where we want to create our service. The useInterpret hook takes a machine, starts it, & returns the resulting service. 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. Another method for starting a machine in React is to use the useMachine hook. This hook causes a re-render each time the machines changes. We want to avoid this as in this scenario, it would cause the whole app to re-render every change time our machine changes. 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 />
);
}

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. Sandbox

We now have our optimized component. Below is a sandbox of our 2 components connected to a global state.