Get the gears clicking - XState + Vue

Working with state machines can be mind-boggling at first. All the complexity and implicit states that become explicit now can be overwhelming - it certainly was for me. But it is not like this complexity wasn't there before. It was just hidden behind cryptic variables, unstructured code and undocumented logic.

Fortunately, the XState ecosystem provides an immediate solution to this challenge. The library offers strong visualization tools for the implemented machines, that allow a rapid comprehension of even the most complex system logic. Here is how to set them up in Vue.

Beginner-friendly XState in Vue

Vue 3 template

I am using the vitesse-lite template as it is one of the fastest ways to prototype something in Vue 3. I am using Vue 3 as it becomes the default option for Vue in the next few days. See my GitHub repo for details. I added three packages in order to use state machines in my project.

pnpm install -D @xstate/inspect
pnpm install xstate @xstate/vue

If you are using Vue 2 there is a package with similar functionality here. Your setup and mileage may vary significantly though. I haven't used it so far. 🤷🏼‍♂️

Using the @xstate/vue composables

Starting with @xstate/vue you will probably use the composable useMachine to instantiate a machine within setup functions. That is great! It makes it really easy to get a machine running, as well as subscribe to a reactive state and send function.

  setup() {
    const { state, send } = useMachine(myFirstMachine)
    return { state, send }
  },

State has superpowers

The xState docs explain how you create the reactive state, but it does not tell you what amazing things you can do with it. (At least not as enthusiastic as I do 🥳) For example, the state object can tell you, if the current state can potentially accept an event through the send method like that:

const stateCanToggle = computed(() => {
 return state.value.nextEvents.includes('TOGGLE')
})

That is useful when we want to display a button only if the action is available in the current state.

Going even further with this display logic we can not only determine if a state can accept a certain event. We can furthermore determine if the event will potentially lead to a change in the state itself. This is especially useful when the events transition is guarded by a condition. For example, if we need to provide some data before we can progress we can show the button to the user because the event can be received in the current state of the machine, but we can display it disabled as the data provided so far is not passing the guard on the event handler.

const stateCanToggle = computed(() => {
 return state.value.can('TOGGLE')
})

Passing partial state to a child components

Sometimes you just want to pass a subset of the state to a child component in Vue. For example, if you have a child actor and want the child component to only take care of this specific part of the machine. At first, I tried accessing the state with a computed prop and extracting the relevant subset of the state, but it didn't work. I must have tried for hours before I asked in their discord - it was new years eve and I was very frustrated 😅. But not 12 hours later I got my answer on how to preserve reactivity when using the state. You must use the useSelector composable! That way the reactivity does not get lost when passed to another component - especially a problem within v-for loops.

  setup() {
    const { state, send, service } = useMachine(myFirstMachine)

    const childMachine = useSelector(
        service,
        (state) => state.context.childMachine
    )

    return { childMachine }
  },

Set initial context of a machine

You will come to a point when you need to set an initial context to a machine. This can be because you want the machine to fetch an entity as a first action based on its context, or you already have some data the machine needs on start up. While you can use a factory function for that, it feels a lot less overhead to use the handy XState method myFirstMachine.withContext in order to populate the state before starting the machine:

  const myFirstMachineWithCtx = myFirstMachine.withContext({
    idToFetch: '12334'
  })

  const { service, send } = useMachine(myFirstMachineWithCtx)

Configure the visualizer

Adding the visualizer makes state machines 100 times more useful right from the start. Especially if you have complex logic parts that are way better to comprehend in visual form. When you start with simple machines it gives you a direct feeling of your creation and instant feedback. Finding errors is also way easier in visual form.

For it to work you need to do two things.

First, you need to globally enable the visualizer. I do that in my main.ts file by globally setting up the inspect utility. We just need to call the inspect function once to configure the package globally. The option iframe would open the inspector on the same page as your application but I like it in a new tab better.

import { createApp } from 'vue'
import { inspect } from '@xstate/inspect'

...

if (import.meta.env.DEV) {
  inspect({
    iframe: false,
  })
}

...

app.mount('#app')

Secondly, you need to enable dev tools on your useMachine hooks. As before I am using the import.meta.env.DEV env variable here to only activate the debug feature in dev mode.

  setup() {
    const { state, send } = useMachine(toggleMachine, { devTools: import.meta.env.DEV })
    return {
      state,
      send,
    }
  },

Having that in place we can start building more complex things. And don't be overly strict with yourself. You will make mistakes building your first machines. E.g. making the wrong part a submachine, or making something a state that would better be a string in the context. I made both of those in my first week.

If you want to read more in-depth stuff about how to avoid those errors, the stately.ai blog is a great resource.