DEV Community

Cover image for Managing Multi-Step Forms in Vue with XState
Maya Shavin 🌷☕️🏡
Maya Shavin 🌷☕️🏡

Posted on • Originally published at mayashavin.com

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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: {},
  },
});
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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'))
Enter fullscreen mode Exit fullscreen mode

We also refactor navigation methods to use send():

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

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
});
Enter fullscreen mode Exit fullscreen mode

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' } },
  },
});
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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' })
Enter fullscreen mode Exit fullscreen mode

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: {},
  },
  //...
});
Enter fullscreen mode Exit fullscreen mode

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 }),
          }),
        },
      },
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

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

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

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 }),
      },
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

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

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

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" />
Enter fullscreen mode Exit fullscreen mode

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,
            }),
          }),
        },
      },
    },
    //...
  },
});
Enter fullscreen mode Exit fullscreen mode

And we bind inputs to this event:

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

And update the template:

<input id="name" placeholder="Name" @input="update" />
<input id="email" placeholder="Email" @input="update" />
Enter fullscreen mode Exit fullscreen mode

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' }) }
Enter fullscreen mode Exit fullscreen mode

And removing formData from the machine definition:

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

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 👇🏼 😉

Top comments (0)