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.
This feature relies on nested routes and the provide/inject mechanism from vue to share data between the pages.
├── .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:
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.
// 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.
<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>
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
.
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:
<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>
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.
<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):
<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>
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>
}