Menu
Documentation

Create pages for the form

Tairo provide helpers to build multiple page forms. This can be useful when you have a form that is too long to be displayed in a single page.

Provide/inject

This feature relies on nested routes and the provide/inject mechanism from vue to share data between the pages.

bash
├── .app/
   ├── pages/
   ├── my-form/
   ├── index.vue # First step
   ├── my-step-2.vue # Second step
   ├── my-step-3.vue # Thrid step
   └── review.vue # Final step
   └── my-form.vue # Parent page

The parent page my-form.vue will contain the form definition and be responsible for the form state. It also should contains a <NuxtPage /> component to render the current step and use provideMultiStepForm.

The my-form/ folder contains the different steps of the form, index.vue being the first step. Each step can then use useMultiStepForm to access the form state and methods.


Useful resources:

Define steps metadata and initial state

First we need to define types for the form data and the steps metadata, this is optional but can be useful to have better type checking.

ts
// types for the form data
export interface MyFormData {
  // step 1
  email: string
  password: string
  passwordCheck: string
  // step 2
  firstName: string
  lastName: string
  // step 3
  role: string | null
}
// types for steps metadata
export interface MyFormStepMeta {
  name: string
  description: string
}

Then we can provide our form initial state, our submit method handler and steps definitions using the provideMultiStepForm function in our parent page.

Steps items requires a to property that should be the path to the step page. The a meta property is optional but can be useful to render the steps in the UI. Another optional property is validate that can be used to validate the form data before moving to the next step, we will see how to use it later.

vue
<script setup lang="ts">
import type { MyFormData, MyFormStepMeta } from '~/types/my-form'

const initialState = ref<MyFormData>({
  // step 1
  email: '',
  password: '',
  passwordCheck: '',
  // step 2
  firstName: '',
  lastName: '',
  // step 3
  role: null,
})

const {
  reset,
  handleSubmit,
} = provideMultiStepForm<MyFormData, MyFormStepMeta>({
  initialState,

  async onSubmit(values) {
    console.log('Form submitted', values)
  },

  steps: [
    {
      to: '/my-form',
      meta: {
        name: 'Step 1',
        description: 'Login details'
      },
    },
    {
      to: '/my-form/my-step-2',
      meta: {
        name: 'Step 2',
        description: 'Account info'
      },
    },
    {
      to: '/my-form/my-step-3',
      meta: {
        name: 'Step 3',
        description: 'Account role'
      },
    },
    {
      to: '/my-form/review',
      meta: {
        name: 'Step 4',
        description: 'Review account creation'
      },
    },
  ],
})

// you can fetch data here and provide it to the form,
// as mutating the initialState won't trigger form state update,
// unless you call reset()
const { data } = useFetch<MyFormData>('/api/my-form-data')
watch(data, (value) => {
  if (value) {
    initialState.value = value
    reset()
  }
}, { immediate: true })
</script>

<template>
  <main>
    <MyFormHeader />

    <form @submit.prevent="handleSubmit">
      <div class="mb-8">
        <NuxtPage />
      </div>

      <MyFormActions />
    </form>
  </main>
</template>

Create steps pages

Once we have our parent page setup, we can create the steps pages under the my-form/ directory.

We will be able to access form context with useMultiStepForm composable. If you omit to call provideMultiStepForm in the parent page, you will get an error when trying to use useMultiStepForm.

Context reference

Check the form context reference below to see all the available methods and properties returned by useMultiStepForm and provideMultiStepForm.

And there are pages examples to use with our form:

vue
<script setup lang="ts">
import type { MyFormData, MyFormStepMeta } from '~/types/my-form'

const { data } = useMultiStepForm<MyFormData, MyFormStepMeta>()
</script>

<template>
  <div class="max-w-sm space-y-2">
    <BaseInput
      v-model="data.email"
      icon="lucide:mail"
      label="Email"
      type="email"
      placeholder="you@acme.com"
    />
    <LazyAddonInputPassword
      v-model="data.password"
      icon="lucide:lock"
      label="Password"
    />
    <BaseInput
      v-model="data.passwordCheck"
      icon="lucide:lock"
      label="Password verification"
      type="password"
    />
  </div>
</template>

Errors and validation

By default, no validation is performed when moving to the next step. You can add a validate function to the step definition to perform validation before moving to the next step.

The validate function get the current form context as argument, in which you can use the setFieldError method to set an error message for a specific field. If you set an error message for a field and don't return anything within validate function, the form won't move to the next step.

vue
<script setup lang="ts">
provideMultiStepForm({
  // global error handler,
  // this will be called when an error is thrown
  // in a validate function or in the onSubmit function
  async onError(_error, _context) {
    //
  },

  // ...

  steps: [
    {
      // ...

      async validate({ data, setFieldError, resetFieldError }) {
        // reset current step errors
        resetFieldError(['email', 'password', 'passwordCheck'])

        // you can use a validation library like zod or yup
        if (!data.value.email) {
          setFieldError('email', 'Email is required')
        }
        else if (!/^[^\s@]+@[^\s@][^\s.@]*\.[^\s@]+$/.test(data.value.email)) {
          setFieldError('email', 'Invalid email')
        }
        else {
          // example of async validation
          const available = await checkEmailAvailability(data.value.email)
          if (!available) {
            setFieldError('email', 'Email already used')
          }
        }

        if (!data.value.password) {
          setFieldError('password', 'Password is required')
        }
        else if (data.value.password.length < 6) {
          setFieldError('password', 'Password must be at least 6 characters')
        }
        else if (data.value.password !== data.value.passwordCheck) {
          setFieldError('passwordCheck', 'Passwords do not match')
        }

        // return nothing to let form move only if no errors
      },
    },
  ],
})
</script>

The setFieldError(key, message) method populate the errors.fields[key] object in the form context, which can be used to display errors in the UI.

In addition, we need to ensure on page load that previous steps are valid, as the user can navigate directly to a step page. We can do this by calling the checkPreviousSteps method in the onBeforeMount hook inside our steps pages.

We can update our form pages to display errors on the fields and check previous steps (only relevant parts are shown):

vue
<script setup lang="ts">
const { errors, checkPreviousSteps } = useMultiStepForm()

onBeforeMount(checkPreviousSteps)
</script>

<template>
  <div class="max-w-sm space-y-2">
    <BaseInput
      :error="errors.fields.email"
    />
    <LazyAddonInputPassword
      :error="errors.fields.password"
    />
    <BaseInput
      :error="errors.fields.passwordCheck"
    />
  </div>
</template>

Form context reference

ts
export interface MultiStepFormContext<
  DATA extends Record<string, any> = Record<string, any>,
  META extends Record<string, any> = Record<string, any>,
> {
  steps: ComputedRef<WithId<StepForm<DATA, META>>[]>
  totalSteps: ComputedRef<number>
  currentStepId: ComputedRef<number>
  currentStep: ComputedRef<WithId<StepForm<DATA, META>>>
  progress: ComputedRef<number>
  isLastStep: ComputedRef<boolean>
  data: Ref<UnwrapRef<DATA>>
  errors: Readonly<Ref<{
    message: string
    fields: Record<string, string | undefined>
  }>>
  loading: Readonly<Ref<boolean>>
  complete: Readonly<Ref<boolean>>

  getStep: (id?: number) => WithId<StepForm<DATA, META>> | undefined
  getNextStep: (id?: number) => WithId<StepForm<DATA, META>> | null
  getPrevStep: (id?: number) => WithId<StepForm<DATA, META>> | null
  goToStep: (step?: WithId<StepForm<DATA, META>>) => Promise<void>
  reset: (initialState?: MaybeRefOrGetter<DATA>) => void
  setErrorMessage: (message?: string) => void
  setFieldError: (field: string, message?: string) => void
  resetFieldError: (field?: string | string[]) => void

  validateStep: (step?: WithId<StepForm<DATA, META>>) => Promise<boolean>
  handleSubmit: () => Promise<void>
  checkPreviousSteps: () => Promise<void>
}