Global State in Next.js using XState
Intended Audience: Front-end developers. Anyone exploring state management or learning XState.
Tended:
Status: mature
This note details how to create global state in a Next.js project using XState. A state machine will control the state. A React context will wrap the machine, removing the need for prop drilling. We will create two React components. One that can read and write to the global state. The 2nd, an optimised version of the first. Showing how to prevent unnecessary renders. The file structure will be:
Create a Machine
Our machine will have 3 states and 3 events. Each event will change the machine's state:
- ▪
button1Clicked
→state1
- ▪
button2Clicked
→state2
- ▪
button3Clicked
→state3
/myMachine.tsx
import { createMachine } from "xstate"; export const myMachine = createMachine({ id: "myMachine", initial: `state1`, on: { button1Clicked: `state1`, button2Clicked: `state2`, button3Clicked: `state3` }, states: { state1: {}, state2: {}, state3: {} } });
Create a React Context
Next, use createActorContext
(provided by @xstate/react
) to create a React context object that will interpret the machine.
/myMachine.tsx
import { createMachine } from "xstate"; import { createActorContext } from '@xstate/react'; export const myMachine = createMachine({ id: "myMachine", initial: `state1`, on: { button1Clicked: `state1`, button2Clicked: `state2`, button3Clicked: `state3` }, states: { state1: {}, state2: {}, state3: {} } }); export const MyMachineReactContext = createActorContext(myMachine);
Start the Machine
In Next.js, the component exported from pages/_app.tsx
persists when the user navigates to different pages.
This is where we want to start our machine.
Use the the context's .Provider
method to get our machine running.
Components can now subscribe to the machine.
/pages/_app.tsx
import * as React from 'react'; import { MyMachineReactContext } from '../src/myMachine'; import '../src/app.css'; export default function App({ Component, pageProps }) { return ( <MyMachineReactContext.Provider> <Component {...pageProps} /> </MyMachineReactContext.Provider> ); }
Access Global State
Use the context's .useActor
method to subscribe to the machine.
The component can now read the machine's state and send events to it.
/ComponentA.tsx
import * as React from 'react'; import { MyMachineReactContext } from './myMachine'; export function ComponentA() { const [state, send] = MyMachineReactContext.useActor(); return ( <section> <h1>Component A</h1> <output> state: <strong>{JSON.stringify(state.value)}</strong> </output> <button onClick={() => send('button1Clicked')}> BUTTON 1 </button> <button onClick={() => send('button2Clicked')}> BUTTON 2 </button> <button onClick={() => send('button3Clicked')}> BUTTON 3 </button> </section> ); };
/pages/index.tsx
import * as React from 'react'; import { ComponentA } from '../ComponentA'; export default function Page() { return ( <ComponentA /> ); }
Optimize
The component we made has a performance issue.
It will re-render when anything changes in our machine.
This could be a problem.
For example, if our component was only interested in knowing if the machine was in state3
.
It would unnesscarily re-render when the machine changes from state1
to state2
.
To prevent this, we can use a selector.
A selector is a function that returns specific information from the machine. Such as is the machine in state3?.
Using this selector, the component will only re-render when the machine enters, or leaves, state3
.
To send events to the machine with subscribing to it (which would cause unnecessary re-renders), we use useActorRef
method instead of useActor
.
This returns a static reference to the machine.
Being static, its value never changes, so will not cause re-renders.
Lets make a new component, ComponentB
, that won't have unnecessary re-renders.
/ComponentB.tsx
import * as React from 'react'; import { MyMachineReactContext } from './myMachine'; let renderCount = 0; export function ComponentB() { const actorRef = MyMachineReactContext.useActorRef(); const isState3 = MyMachineReactContext.useSelector((state) => state.matches('state3') ); renderCount++; return ( <section> <h1>Component B</h1> <output> isState3: <strong>{JSON.stringify(isState3)}</strong> </output> <output> renderCount: <strong>{renderCount}</strong> </output> <button onClick={() => actorRef.send('button3Clicked')}>BUTTON 3</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 /> </> ); }
Persist State
A common requirement is to persist and rehydrate global state.
Saving the state so when a user reopens your app, they land where they left off.
The 3rd argument of createActorContext
accepts an observer.
A function called whenever the state changes.
We can use this to store the latest state in local storage.
/pages/_app.tsx
import { createActorContext } from '@xstate/react'; import { createMachine } from 'xstate'; export const myMachine = createMachine({ id: 'myMachine', initial: `state1`, on: { button1Clicked: `state1`, button2Clicked: `state2`, button3Clicked: `state3`, }, states: { state1: {}, state2: {}, state3: {}, }, }); const LOCAL_STORAGE_KEY = "myPersistedState"; export const MyMachineReactContext = createActorContext( myMachine, {}, (state) => { localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(state)) } );
Rehydrate State
The 2nd argument of createActorContext
is an options object.
It has a state property that accepts a state object.
If provided, the machine will start with that value, instead of its initial state.
We can use our persisted state here.
/pages/_app.tsx
import { createActorContext } from '@xstate/react'; import { createMachine } from 'xstate'; export const myMachine = createMachine({ id: 'myMachine', initial: `state1`, on: { button1Clicked: `state1`, button2Clicked: `state2`, button3Clicked: `state3`, }, states: { state1: {}, state2: {}, state3: {}, }, }); const LOCAL_STORAGE_KEY = "myPersistedState"; function rehydrateState() { // Required because Next.js will initially load MyMachineReactContext on the server if (typeof window === "undefined") { return myMachine.initialState; } return ( JSON.parse(localStorage.getItem(LOCAL_STORAGE_KEY)) || myMachine.initialState ); } export const MyMachineReactContext = createActorContext( myMachine, { state: rehydrateState() }, (state) => { localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(state)) } );
Sandbox
We now have our optimized component. Below is a sandbox of what we created. I wasn't able to get it working with local storage, so persisting and rehydrating isn't included.