Brad Woods Digital Garden

Notes

The Warhammer 40k Adeptus Mechanicus symbol

    The Weighted Companion Cube from Aperture
    A system diagram made up of UI components, context, state machine and local storage

    Planted: 

    Tended: 

    Status: mature

    Intended Audience: Front-end developers. Anyone exploring state management or learning XState.

    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

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

    A statechart of the machine

    /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>
    <h2>Component A</h2>
    <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>
    <h2>Component B</h2>
    <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. I wasn't able to get it working with local storage, so persisting and rehydrating isn't included. Sandbox.

    Subscribe

    Where to next?

    JavaScript
    Arrow pointing downYOU ARE HERE
    A sci-fi robot taxi driver with no lower body