Basic knowledge: VueTypeScriptXStatevee-validatezod

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:

  1. Field state management
  2. All field validation

After delegating the above tasks to the form components, the remaining logic to handle is:

  1. Display the state of which stage form component is currently active
  2. 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

  1. Control flow between stages (determining which form to display at each stage)
  2. Management of all form data
  3. Data submission and asynchronous request handling
  4. Handling of asynchronous request states (loading and error handling)

All of these features are implemented using XState.

Form Components For Each Stages

  1. Form field state management (using vee-validate)
  2. Form field validation (using zod)
  3. 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:

  1. Form1 emits a NEXT_TO_STEP_2 event to proceed to Form2, or Form2 emits a PREV event to return to Form1, and so on.
  2. FormConfirm emits a SUBMIT event to tell the state machine to execute an asynchronous request, sending out the form data.
  3. The state machine’s context stores the field data for Form1 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

Page

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. 🙏