Basic knowledge:
Vue
、TypeScript
、XState
、vee-validate
、zod
Initial Concept
We often see “multi-step” forms that break down a lengthy form into several separate sections for completion. This approach reduces the psychological burden on users compared to a single long-page form.
Among the numerous form-related open-source packages available, I’ve selected the following tools to manage form-related tasks:
- Form state management: Using Vue for this example, vee-validate manages the rendering states of all form elements including
input
,select
, and other form components - Form validation: For validation tasks, we use zod (officially recommended by vee-validate) to handle the validation logic
Now that we’ve covered the form components, the key question remains: how should we design this “multi-step” architecture?
If we combine all fields from each stage into a single form, as shown in the diagram:
When switching between stages, we need to determine which fields to display, but when moving to the next stage, we need to validate only the current set of fields, which leads to complex validation logic.
So I thought of making each stage an independent form, meaning all validation is no longer partial, but rather validates all fields within a form (for example, all fields in stage one):
By splitting into multiple forms, we’ve simplified the form validation logic, eliminating the need for additional checks (partial field validation). The responsibilities of each form component are as follows:
- Field state management
- All field validation
After delegating the above tasks to the form components, the remaining logic to handle is:
- Display the state of which stage form component is currently active
- Submit form data and execute asynchronous requests
Since “Stage One” can only proceed to “Stage Two” and not to “Stage Three” or “Confirmation Stage (Step Confirm)”, I thought of using a finite state machine to solve these two issues, and decided to try XState, a well-known package for implementing finite state machines, to handle these tasks.
Therefore, the responsibility distribution diagram is as follows:
Assignment of Responsibilities
The previous section outlined the initial ideas. Here, let’s organize the planned assignment of responsibilities, which is divided into two parts:
State Machine
- Control flow between stages (determining which form to display at each stage)
- Management of all form data
- Data submission and asynchronous request handling
- Handling of asynchronous request states (loading and error handling)
All of these features are implemented using XState.
Form Components For Each Stages
- Form field state management (using vee-validate)
- Form field validation (using zod)
- Form submission events and form data (using vee-validate)
Implementation Overview
Form Components
For the form component, taking the first stage Form1.vue as an example, the structure is as follows:
<template>
<div>
<h2 class="formTitle">Choose channels you like</h2>
<form class="form" @submit="onSubmit">
<div>
<input type="checkbox" id="discovery" :value="1" v-model="channels" />
<label class="label" for="discovery">Discovery</label>
</div>
<!-- other inputs... -->
<div v-if="errors.channels" class="error">{{ errors.channels }}</div>
<div class="buttonGroup">
<button class="button" type="submit">next step</button>
</div>
</form>
</div>
</template>
<script setup lang="ts">
import type { Form1Model } from "@/types";
import { toTypedSchema } from "@vee-validate/zod";
import { useForm, useField } from "vee-validate";
import z from "zod";
interface Props {
initialValues: Form1Model;
}
interface Emits {
(event: "next", values: Form1Model): void;
}
const props = withDefaults(defineProps<Props>(), {
class: "",
});
const emits = defineEmits<Emits>();
const validationSchema = toTypedSchema(
z.object({
channels: z.number().array().nonempty("Please choose at least one channel."),
})
);
const { handleSubmit, errors, values } = useForm<Form1Model>({
initialValues: props.initialValues,
validationSchema,
});
const { value: channels } = useField<number[]>("channels");
const onSubmit = handleSubmit((values) => emits("next", values));
</script>
The form’s initial values are provided by the state machine and passed in via props
; then when the form is submitted, it emits events for the state machine to handle. One person is responsible for one thing, embodying the spirit of the “Single Responsibility Principle.”
Wrapper Components
The display control for forms at each stage is implemented in the outer MultiStepForm.vue
, which imports the state machine and determines the display logic. Each form emits various events (next step, previous step, submit, etc.), which are then handed over to the state machine for execution.
<template>
<div :class="`h-full w-full ${props.class}`">
<h1 class="title">Multi Step Form Example</h1>
<div class="grid grid-cols-2 gap-x-6">
<div>
<h2 class="subTitle">Form Component</h2>
<div class="p-4 border border-slate-700 rounded-lg">
<Form1
v-if="state.matches('step1')"
@next="send('NEXT_TO_STEP_2', { formValues: $event })"
@prev="send('PREV')"
:initial-values="state.context.form1Values"
/>
<Form2
v-if="state.matches('step2')"
@next="send('NEXT_TO_STEP_3', { formValues: $event })"
@prev="send('PREV')"
:initial-values="state.context.form2Values"
/>
<Form3
v-if="state.matches('step3')"
@next="send('NEXT_TO_STEP_CONFIRM', { formValues: $event })"
@prev="send('PREV')"
:initial-values="state.context.form3Values"
/>
<FormConfirm
v-if="state.matches('stepConfirm')"
@prev="send('PREV')"
@submit="send('SUBMIT')"
:is-submitting="state.matches('stepConfirm.submitting')"
:error="state.context.error"
:machine-context="state.context"
:payload="state.context.payload"
/>
<FormComplete v-if="state.matches('complete')" @restart="send('RESTART')" />
</div>
</div>
<div>
<p class="subTitle">Current Machine Context</p>
<pre class="preBlock">{{ state.context }}</pre>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useMachine } from "@xstate/vue";
import Form1 from "@/components/Form1.vue";
import Form2 from "@/components/Form2.vue";
import Form3 from "@/components/Form3.vue";
import FormConfirm from "@/components/FormConfirm.vue";
import FormComplete from "@/components/FormComplete.vue";
import { multiStepFormMachine } from "@/multiStepFormMachine";
interface Props {
class?: string;
}
interface Emits {
(event: "click"): void;
}
const props = withDefaults(defineProps<Props>(), {
class: "",
});
const emits = defineEmits<Emits>();
const { state, send } = useMachine(multiStepFormMachine);
</script>
State Machine of Form
Next is the state machine, which I’ve separated into a standalone file multiStepFormMachine.ts
for easier management:
import { assign, createMachine } from "xstate";
import type { Form1Model, Form2Model, Form3Model, SubmitData } from "./types";
import { FORM_1_INITIAL_VALUES, FORM_2_INITIAL_VALUES, FORM_3_INITIAL_VALUES } from "./default";
import { sendFormData } from "./utils";
type MachineEvent =
| { type: "NEXT_TO_STEP_2"; formValues: Form1Model }
| { type: "NEXT_TO_STEP_3"; formValues: Form2Model }
| { type: "NEXT_TO_STEP_CONFIRM"; formValues: Form3Model }
| { type: "PREV" }
| { type: "SUBMIT" }
| { type: "RESTART" };
export type MachineContext = {
form1Values: Form1Model;
form2Values: Form2Model;
form3Values: Form3Model;
payload: SubmitData | null;
error: string | null;
};
const INITIAL_MACHINE_CONTEXT: MachineContext = {
form1Values: FORM_1_INITIAL_VALUES,
form2Values: FORM_2_INITIAL_VALUES,
form3Values: FORM_3_INITIAL_VALUES,
payload: null,
error: null,
};
type MachineState =
| { context: MachineContext; value: "step1" }
| { context: MachineContext; value: "step2" }
| { context: MachineContext; value: "step3" }
| { context: MachineContext; value: "stepConfirm" }
| { context: MachineContext; value: "stepConfirm.submitting" }
| { context: MachineContext; value: "complete" };
export const multiStepFormMachine = createMachine<MachineContext, MachineEvent, MachineState>(
{
id: "multiStepForm",
initial: "step1",
context: INITIAL_MACHINE_CONTEXT,
states: {
step1: {
on: {
NEXT_TO_STEP_2: {
target: "step2",
actions: assign({
form1Values: (context, event) => event.formValues,
}),
},
},
},
step2: {
on: {
NEXT_TO_STEP_3: {
target: "step3",
actions: assign({
form2Values: (context, event) => event.formValues,
}),
},
PREV: {
target: "step1",
},
},
},
step3: {
on: {
NEXT_TO_STEP_CONFIRM: {
target: "stepConfirm",
actions: assign({
form3Values: (context, event) => event.formValues,
}),
},
PREV: {
target: "step2",
},
},
},
stepConfirm: {
initial: "preSubmit",
states: {
preSubmit: {
entry: assign({
payload: (context, event) => ({
...context.form1Values,
...context.form2Values,
...context.form3Values,
}),
}),
on: {
SUBMIT: {
target: "submitting",
},
},
},
submitting: {
invoke: {
src: "formSubmit",
onDone: {
target: "#multiStepForm.complete",
actions: "resetContext",
},
onError: {
target: "errored",
actions: assign({
error: (context, event) => event.data.error,
}),
},
},
},
errored: {
on: {
SUBMIT: {
target: "submitting",
},
},
},
},
on: {
PREV: {
target: "step3",
},
},
},
complete: {
entry: "resetContext",
on: {
RESTART: {
target: "step1",
},
},
},
},
},
{
actions: {
resetContext: assign(INITIAL_MACHINE_CONTEXT),
},
services: {
formSubmit: async (context, event) => {
if (context.payload) {
return await sendFormData(context.payload);
} else {
return await new Promise((resolve, reject) => reject("Context cannot be null."));
}
},
},
}
);
For details of the state machine’s operation flow, please refer to this visualization page: multi-step-form | Stately
Above is the state machine code - Sorry for too lengthy. 🙏
The main responsibilities:
Form1
emits aNEXT_TO_STEP_2
event to proceed toForm2
, orForm2
emits aPREV
event to return toForm1
, and so on.FormConfirm
emits aSUBMIT
event to tell the state machine to execute an asynchronous request, sending out the form data.- The state machine’s
context
stores the field data forForm1
and other form components, as well as the payload for the final request submission, along with the status of asynchronous requests (loading, error)
Final Result
Below are the final implementation results, with form components on the left and the current context
status on the right, clearly showing when the context
data gets updated
Please refer to here for all code
Notes
Above are some ideas for multi-stage forms, using state machines to achieve single responsibility for forms while clearly separating and delegating logic to different parts.
This is my first time seriously writing a state machine on my own, and I’m still in the learning and exploration phase. If you have any questions, feel free to leave a comment. 😎
Happy coding. 🙏