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
- Table of Contents
- The challenge of managing state a multi-step sign-up form wizard
- What Are State Machines & XState?
- Building a sign-up form wizard machine with XState
- Mapping Component's Data to Context
- Resources
- Summary
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.
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.
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
:
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)