DEV Community

Cover image for Sustainable xState machines
Georgi Todorov
Georgi Todorov Subscriber

Posted on • Edited on

Sustainable xState machines

TLDR

If you are curious about the full-working example, it is here.

Background

I've been using xState with React for some time already. Throughout the projects' development, I often find myself reusing the same machine in different contexts. Sometimes makes sense to interpret the machine from the React component, other times it is the logical choice to spawn and store it as a child actor.
That's why I came up with a simple abstraction that let's me use the machine in both ways.

Use case example

Let's say we need a toggle machine for our checkbox component. We create a simple machine with on and off states and then use it directly in our checkbox component via the useMachine hook.
But in another page we have a more complex machine, which can make good use of the toggle machine for displaying a notification based on business logic. This sounds as a good opportunity to spawn a toggle actor, which will be controlled by the complex machine.
Now we need the same machine, but utilised in two different ways.

Checkbox component

Firstly, we need to create our toggle machine.

export const toggleMachine = createMachine({
  id: "toggleMachine",
  schema: {
    events: {} as { type: "TOGGLE" },
  },
  initial: "off",
  states: {
    off: {
      on: {
        TOGGLE: {
          target: "on",
        },
      },
    },
    on: {
      on: {
        TOGGLE: {
          target: "off",
        },
      },
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

It is a simple statechart with on and off states. This will be sufficient for our checkbox component. We just have to pass it to the useMachine hook.

interface Props {
  onChange(checked: boolean): void;
}

export function Checkbox({ onChange }: Props) {
  const [state, send] = useMachine(toggleMachine);

  return (
    <>
      <label htmlFor="toggle">Toggle</label>
      <input
        id="toggle"
        type="checkbox"
        checked={state.matches("on")}
        onChange={(event) => {
          send({ type: "TOGGLE" });
          onChange(event.target.checked);
        }}
      />
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

We use the matches method to confirm the machine state and control the checked attribute. When onChange is triggered, the machine state is toggled and the callback from the props is fired.

The only problem that we are facing now is that we cannot control the initial state of the checkbox component. It will always be initialised as unchecked until further interaction occurs.

We can easily solve the issue by lazy-loading the machine. By returning the machine from a factory function, we can pass the initial state as a variable.

The final step, is to lazily-create the machine with the useMachine hook. This will prevent the warnings for a new machine instance being passed to the hook on each re-render.

export function toggleMachine({ initial }: { initial: string }) {
  return createMachine({
    id: "toggleMachine",
    initial,
    states: {
     /* ... */
    },
  });
}

/* ... */

const [state, send] = useMachine(
  () => toggleMachine({ initial })
);
Enter fullscreen mode Exit fullscreen mode

Data fetching notification

Supposing we need to show specific data to the user when downloaded. To make it sure that the user won't miss the information, we put it in a notification component.

We can start by creating the fetching machine (parent machine) that takes care of downloading the user info.

const fetchingMachine = createMachine(
  {
    id: "fetchMachine",
    initial: "fetching",
    on: {
      FETCHING: {
        target: "fetching",
      },
    },
    states: {
      idle: {},
      fetching: {
        invoke: {
          src: "fetchData",
          onDone: {
            target: "idle",
          },
        },
      },
    },
  },
  {
    services: {
      async fetchData() {
        const response = await fetch(
          "https://jsonplaceholder.typicode.com/todos/"
        );
        const data = await response.json();

        return data;
      },
    },
  }
);
Enter fullscreen mode Exit fullscreen mode

Similarly to the Checkbox component, the Notificaion one needs on and off states, in order to control its visibility. The difference now is that instead of interpreting the machine directly in the component, we can spawn an actor and keep it in the fetching machine's context.

In my opinion, this approach gives us more flexibility when it comes to controlling the toggle machine. We can spawn the machine explicitly when needed, instead of relying on the React render cycle.

const fetchingMachine = createMachine(
  {
    /* ... */
    states: {
      idle: {},
      fetching: {
        invoke: {
          src: "fetchData",
          onDone: {
            actions: ["assignToggleRef"],
            target: "idle",
          },
        },
      },
    },
  },
  {
    actions: {
      assignToggleRef: assign({
        toggleRef: (context, event) => {
          return spawn(toggleMachine({ initial: "on" }));
        },
      }),
    },
    services: {
      async fetchData() {
        /* ... */
      },
    },
  }
);
Enter fullscreen mode Exit fullscreen mode

We can simply pass the toggleRef to the Notification component and interpret it from there with the guarantee that the fetching of the data is completely finished.

interface Props {
  actor: ActorRefFrom<typeof toggleMachine>;
}

export function Notification({ actor, data }: Props) {
  const [state, send] = useActor(actor);

  return (
    <div>
      {state.matches("on") && <div>Very important notification</div>}
      <button
        onClick={() => {
          send({ type: "TOGGLE" });
        }}
      >
        {state.matches("on") ? "close" : "open"}
      </button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Now we have access to the actor's state and context. Also, since the actor reference is kept in the parent's machine context, we can send events to the interpreted actor regardless of the parent's machine state.

Unite both worlds

Using directly the exported machine is perfectly fine, but I fancy adding one more layer of abstraction.

import { ActorRefFrom, createMachine, spawn } from "xstate";

export type ToggleMachineActor = ActorRefFrom<typeof toggleMachine>;

function toggleMachine({ initial }: { initial: string }) {
  return createMachine({
    id: "toggleMachine",
    /* ... */
  });
}

export function toggleMachineCreate({ initial = "off" }): {
  machine: ReturnType<typeof toggleMachine>;
  spawn: () => ToggleMachineActor;
} {
  const machine = toggleMachine({ initial });

  return {
    machine,
    spawn: () => spawn(machine, { name: "toggleMachine" }),
  };
}

Enter fullscreen mode Exit fullscreen mode

Now, our toggleMachineCreate returns both the machine instance and the spawned actor, which I find a bit cleaner when it comes to using the machine.

toggleRef: (context, event) => {
  return toggleMachineCreate({ initial: "on" }).spawn();
}

/* or */

const [state, send] = useMachine(
  () => toggleMachineCreate({ initial }).machine
);
Enter fullscreen mode Exit fullscreen mode

Another advantage of this approach is that we have a convenient place where we can extend the machines with the withContext and withConfig utility methods. This gives our code а pinch of readability.

export function toggleMachineCreate({ initial = "off", specialService = Promise<any> }): {
  machine: ReturnType<typeof toggleMachine>;
  spawn: () => ToggleMachineActor;
} {
  const machine = toggleMachine({ initial }).withConfig({
    services: { specialService },
  });

  return {
    machine,
    spawn: () => spawn(machine, { name: "toggleMachine" }),
  };
}
Enter fullscreen mode Exit fullscreen mode

On top of that, you can easily pass arguments to the spawn function too. That might be helpful when multiple actors from a machine are spawned and stored into a single context. Distinguishing the references by name, might be helpful when it comes to communication with them.

export function toggleMachineCreate(): {
  /* ... */

  return {
    machine,
    spawn: (name: string) => spawn(machine, { name }),
  };
}

/* ... */

actions: {
  assignToggleRef1: assign({
    toggleRef1: (context, event) => {
      return toggleMachineCreate().spawn("toggleMachine1");
    },
  }),
  assignToggleRef2: assign({
    toggleRef2: (context, event) => {
      return toggleMachineCreate().spawn("toggleMachine2");
    },
  }),
},

/* ... */
Enter fullscreen mode Exit fullscreen mode

And last but not least, I find it really handy to have the actor type exported along with the toggleMachineCreate function. It is quite useful when it comes to prop drilling the actor and defining the prop types.

export type ToggleMachineActor = ActorRefFrom<typeof toggleMachine>;

/* ... */

interface Props {
  actor: ToggleMachineActor;
}
Enter fullscreen mode Exit fullscreen mode

Opinionated conclusion

This pattern scales greatly and gives a good amount of predictability in regards to code organisation and readability.

Top comments (0)