Left Arrow.Back
Left Arrow.Back

Notes / JavaScript / State Management / Global State - Next.js + XState

Global State - Next.js + XState

A system diagram of squares connected by a line to a circle in the middle.

Global State in Next.js using XState

Last Tended

Status: sprout

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. This example will have the following file structure:

my-next-app  ├── pages   
├── _app.ts
└── index.tsx
├── src
├── app.css
├── ComponentA.tsx
├── ComponentB.tsx
├── contexts.ts
└── myMachine.ts
...
2 cogs rotating

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:

  • button1Clickedstate1
  • button2Clickedstate2
  • button3Clickedstate3
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);
2 cogs rotating

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. This will cause a component to re-render every time the machine changes.
  • useInterpret: starts a machine & returns a service (a static reference) of the machine. Being static, its value never changes, so it will not cause re-renders. This can be subscribed to & is what we will do within other components that we do want to re-render when 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. We want to minimize re-renders of this component, 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 '../src/contexts';
import { myMachine } from '../src/myMachine';
import '../src/app.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 />
);
}
A 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>;
type MyMachineState = StateFrom<typeof myMachine>;
export function selectIsState3(state: MyMachineState) {
return 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 '../src/contexts';
import { myMachine } from '../src/myMachine';
import '../src/app.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 '../src/contexts';
import { myMachine } from '../src/myMachine';
import '../src/app.css';
const STORAGE_KEY = "myPersistedState";
function rehydrateState() {
// 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>
);
}

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