Left Arrow

Notes

Global State

A system diagram made up of UI components, context, state machine and local storage

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:

my-next-app
pages
_app.ts
index.tsx
src
app.css
ComponentA.tsx
ComponentB.tsx
myMachine.ts
Cog

Create a Machine

A statechart of the machine

Our machine will have 3 states and 3 events. Each event will change the machine's state:

  • button1Clickedstate1
  • button2Clickedstate2
  • button3Clickedstate3

/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: {}
  }
});
System diagram

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);
Cog

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>
  );
}
System diagram

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 />
  );
}
System diagram

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.