Managing Multi-Step Forms in Vue with XState

Feb 12, 2025 · 8 min read
Share on
Managing Multi-Step Forms in Vue with XState

State management is essential for any application, ensuring a consistent data flow and predictable behavior. Choosing the right approach impacts scalability and maintainability.

In this article, we'll explore how to refactor a multi-step sign-up form in Vue.js to use XState, a state management library based on finite state machines, making state handling more structured and efficient.

Table of Contents

The challenge of managing state a multi-step sign-up form wizard

Let's say we have a sign-up form built as a two-step wizard: one step to collect the user's name and another for their email address. We'll call this component SignupFormWizard.

<template>
  <form>
    <div class="form-main-view">
      <div v-if="isNameStep">
        <label for="name">Name</label>
        <input id="name" placeholder="Name" />
      </div>
      <div v-else-if="isEmailStep">
        <label for="email">Email</label>
        <input id="email" placeholder="Email" />
      </div>
      <div v-else-if="isSubmitStep">
        <p>Submitting...</p>
      </div>
    </div>
    <div>
      <button @click="prev" v-if="isEmailStep">Prev</button>
      <button @click="next" v-if="isNameStep">Next</button>
      <button @click="submit" v-else-if="isEmailStep">Submit</button>
    </div>
  </form>
</template>

Here's how we manage the local state in the script section:

<script setup>
import { ref, reactive, computed } from 'vue'

const formData = reactive({ name: '', email: '' })
const step = ref(1)

const isNameStep = computed(() => step.value === 1)
const isEmailStep = computed(() => step.value === 2)
const isSubmitStep = computed(() => step.value === 3)
const prev = () => { step.value > 1 && step.value-- }
const next = () => { step.value < 3 && step.value++ }
const submit = () => { step.value = 3 }
</script>

Our form behaves as follows:

In this implementation, we track the form's step using a step variable with ref(), store form data in a reactive object, and define functions to handle navigation between steps and submission.

For a simple form like this, this approach works fine. However, as the form grows in complexity, state management becomes more challenging. If we need to add more steps, handle asynchronous submission (including error and success states), or introduce additional logic, the code can quickly become difficult to maintain.

So, how can we simplify this process while making it more predictable and scalable? Let's explore a better approach.

What Are State Machines & XState?

State machines are models that define a system's behavior by breaking it down into a finite number of states. Transitions between these states occur one at a time, triggered by predefined events.

In simple terms, states act like nodes in a graph, while events function as edges connecting these nodes. Every state and transition is explicitly defined.

State machines are particularly useful for managing complex systems because they provide a structured and predictable way to handle application state. A classic example is a traffic light system, which consists of three main states: red, yellow, and green. The system follows a strict sequence—transitioning from red to yellow, then from yellow to green, and back—using a next() event that can be scheduled.

List of cards displayed in browser with minimum CSS

Building on this concept, XState provides a declarative and predictable approach to state management in TypeScript. It can integrate with modern front-end frameworks like React and Vue through dedicated packages such as @xstate/react and @xstate/vue.

Next, let's explore how we can refactor our SignupFormWizard component to use XState.

Building a sign-up form wizard machine with XState

To integrate XState, we install the necessary packages:

npm install xstate @xstate/vue

Defining the State Machine

We create a new file, machines/signUpMachine.js, and set up the state machine:

import { setup } from 'xstate';

const signUpMachineConfig = setup({
  id: 'signUpMachine',
});

export const signUpMachine = signUpMachineConfig.createMachine({
  initial: 'name',
  context: {},
  states: {
    name: { 
      on: { NEXT: 'email' } 
    },
    email: { 
      on: { PREV: 'name', SUBMIT: 'onsubmit' } 
    },
    onsubmit: {},
  },
});

This defines a three-state machine (name, email, and onsubmit) with transitions triggered by NEXT, PREV, and SUBMIT events.

Integrating XState in the Component

In SignupFormWizard.vue, we connect the machine using useMachine() from @xstate/vue:

<script setup>
import { useMachine } from '@xstate/vue';
import { signUpMachine } from '../machines/signUpMachine';

const { snapshot: state, send } = useMachine(signUpMachine);
</script>

We then replace the local step variable with state.matches() for checking the active state:

const isNameStep = computed(() => state.value.matches('name'))
const isEmailStep = computed(() => state.value.matches('email'))
const isSubmitStep = computed(() => state.value.matches('onsubmit'))

We also refactor navigation methods to use send():

const prev = () => { send({ type: 'PREV' }) }
const next = () => { send({ type: 'NEXT' }) }
const submit = () => { send({ type: 'SUBMIT' }) }

By doing so, we keeps the UI the same but makes state management more predictable.

Next, let's add more features to our form machine, such as asynchronous submission.

Adding Asynchronous Submission

To handle an asynchronous function and its states, we use fromPromise helper as follows:

import { fromPromise } from 'xstate';

const submitForm = fromPromise(async (data) => {
  // Handle submission logic
});

fromPromise() then creates an XState actor that triggers the onDone event when the async function is resolved, and onError event when rejected.

We then modify the onsubmit state to invoke this function:

export const signUpMachine = signUpMachineConfig.createMachine({
  //...
  states: {
    onsubmit: {
      invoke: {
        src: submitForm,
        onDone: 'success',
        onError: 'error',
      },
    },
    success: { on: { RESET: 'name' } },
    error: { on: { RETRY: 'onsubmit' } },
  },
});

In this setup, we also add RETRY and RESET events to error and success states, respectively, to handle the retry and reset functionalities.

Next, let's modify our component to reflect these changes.

Updating the UI for Submission Status

We modify the template to display success and error states:

<template>
  <form>
    <div class="form-main-view">
    <!--...-->
      <div v-else-if="isSuccess">
        <p>Form submitted successfully!</p>
        <button @click="reset">Reset</button>
      </div>
      <div v-else-if="isError">
        <p>Submission failed</p>
        <button @click="retry">Retry</button>
      </div>
    </div>
    <!--...-->
  </form>
</template>

And we update the component logic:

const isSuccess = computed(() => state.matches('success'));
const isError = computed(() => state.matches('error'));

const reset = () => send({ type: 'RESET' })
const retry = () => send({ type: 'RETRY' })

With these changes, users can now retry or reset the form after submission.

Flow of the form with successful submission

Passing Data to submitForm

To store and pass form data, update the machine’s context:

export const signUpMachine = signupMachineConfigs.createMachine({
  //...
  context: {
    formData: {},
  },
  //...
});

Then, we update the SUBMIT event to perform side actions and update the context data with assign() method:

export const signUpMachine = signUpMachineConfig.createMachine({
  //...
  states: {
    //...
    email: {
      on: {
        //...
        SUBMIT: {
          target: 'onsubmit',
          actions: assign({
            formData: (context, event) => ({ ...event.formData }),
          }),
        },
      },
    },
  },
});

In SignupFormWizard, we modify the submit() function to include the form data:

const submit = () => { send({ type: 'SUBMIT', formData: formData.value }) }

To ensure the submitForm function receives the required form data, we update the onsubmit state to include an input field:

export const signUpMachine = signUpMachineConfig.createMachine({
  //...
  states: {
    //...
    onsubmit: {
      invoke: {
        src: submitForm,
        input: ({ context }) => ({ ...context.formData }),
      },
    },
  },
});

Xstate then injects then input value into submitForm function, as follows:

const submitForm = fromPromise(async ({ input }) => {
  console.log([input.name, input.email]);
  //actual logic
});

We can also replace the local formData state in the component with the machine's context.formData instead. We do that next.

Mapping Component's Data to Context

To bind form inputs to the machine's context, we can use v-model:

<input id="name" placeholder="Name" v-model="state.context.formData.name" />
<input id="email" placeholder="Email" v-model="state.context.formData.email" />

Alternatively, we can use an UPDATE event to modify context dynamically:

export const signUpMachine = signupMachineConfigs.createMachine({
  //...
  states: {
    name: {
      on: {
        NEXT: 'email',
        UPDATE: {
          actions: assign({
            formData: (context, event) => ({
              ...context.formData,
              name: event.value,
            }),
          }),
        },
      },
    },
    email: {
      on: {
        PREV: 'name',
        UPDATE: {
          actions: assign({
            formData: (context, event) => ({
              ...context.formData,
              email: event.value,
            }),
          }),
        },
      },
    },
    //...
  },
});

And we bind inputs to this event:

const update = ($event) => send({ type: 'UPDATE', value: $event.target.value });

And update the template:

<input id="name" placeholder="Name" @input="update" />
<input id="email" placeholder="Email" @input="update" />

Since @input triggers on every keystroke, consider wrapping it with a debounce function for better performance.

With this setup, our machine’s context stays in sync with the UI, eliminating the need to pass formData in the SUBMIT event:

const submit = () => { send({ type: 'SUBMIT' }) }

And removing formData from the machine definition:

export const signUpMachine = signUpMachineConfig.createMachine({
  //...
  states: {
    //...
    email: { 
      on: { 
        //...
        SUBMIT: { target: 'onsubmit' } 
      } 
    },
  },
});

That's it! We've successfully refactored our multi-step form wizard to use XState for robust state management. Our state machine's flow now looks like this, with the initial state of name:

State machine diagram for multi-step form

Additionally, we can use XState’s visualizer tool to visualize our machine logic, by importing our code.

Resources


Summary

In this article, we explored how to manage a multi-step form in Vue.js using XState. We refactored our form to leverage state machines for predictable state management and introduced features like asynchronous submission and context-based data handling. This approach enhances maintainability and can be applied across different front-end frameworks.

What's next?

We can further improve our form by adding guards to prevent invalid transitions and dynamically updating the UI based on conditions. Try experimenting with XState and see how it simplifies state management in your projects!

👉 Learn about Vue 3 and TypeScript with my new book Learning Vue!

👉 If you'd like to catch up with me sometimes, follow me on X | LinkedIn.

Like this post or find it helpful? Share it 👇🏼 😉

Share on

Learning Vue

Learn the core concepts of Vue.js, the modern JavaScript framework for building frontend applications and interfaces from scratch

Get a copy
Learning Vue