Skip to content

Forms & Validation ​

Form handling with VeeValidate and Zod validation schemas.

Setup ​

Forms use VeeValidate + Zod for validation:

vue
<script setup>
import * as z from 'zod'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'

const { t } = useI18n()

// Define Zod schema
const schema = computed(() => toTypedSchema(z.object({
  email: z.string()
    .min(1, { message: t('fields.required.email') })
    .email({ message: t('fields.format.email') }),
  password: z.string()
    .min(8, { message: 'Password must be at least 8 characters' })
})))

// Create form instance
const form = useForm({
  validationSchema: schema,
  initialValues: {
    email: '',
    password: ''
  },
  validateOnMount: false // Don't validate until user interacts
})
</script>

Why computed() for schemas?

Wrap schemas in computed() when using t() to ensure translations update reactively when language changes.

Form Fields ​

Use UiFormField + UiFormGroup for field layout:

vue
<template>
  <form @submit.prevent="onSubmit">
    <!-- Single field -->
    <UiFormField name="email" v-slot="{ componentField }">
      <UiFormGroup 
        :label="t('fields.labels.email')" 
        required
      >
        <UiInput 
          v-bind="componentField" 
          type="email"
          :placeholder="t('fields.placeholders.email')"
        />
      </UiFormGroup>
    </UiFormField>

    <!-- Multiple fields in a row -->
    <div class="flex flex-wrap gap-4 items-start">
      <UiFormField name="firstName" v-slot="{ componentField }">
        <UiFormGroup 
          :label="t('fields.labels.firstName')" 
          required
          class="flex-1 min-w-[200px]"
        >
          <UiInput v-bind="componentField" />
        </UiFormGroup>
      </UiFormField>

      <UiFormField name="lastName" v-slot="{ componentField }">
        <UiFormGroup 
          :label="t('fields.labels.lastName')" 
          required
          class="flex-1 min-w-[200px]"
        >
          <UiInput v-bind="componentField" />
        </UiFormGroup>
      </UiFormField>
    </div>

    <UiButton type="submit">Submit</UiButton>
  </form>
</template>

What v-bind="componentField" does:

  • Binds value, name, and event handlers
  • Connects the input to VeeValidate's validation system
  • Automatically shows validation errors via FormMessage

Validation Patterns ​

Common Zod Patterns ​

typescript
// Required string
z.string().min(1, { message: 'Field is required' })

// Email
z.string()
  .min(1, { message: 'Email is required' })
  .email({ message: 'Invalid email address' })

// Regex validation (phone, SSN, etc.)
z.string()
  .min(1, { message: 'Phone is required' })
  .regex(/^\d{10}$/, { message: 'Phone must be 10 digits' })

// Numbers with coercion (for inputs)
z.coerce.number({ required_error: 'Number required' })
  .min(0, { message: 'Must be 0 or greater' })

// Optional fields
z.string().optional()
z.coerce.number().optional().or(z.literal(undefined))

// Enums
z.enum(['manager', 'hr', 'auto'], { 
  required_error: 'Please select an option' 
})

// Custom validation with refine
z.string()
  .min(1, { message: 'Address is required' })
  .refine((val) => validateAddress(val), {
    message: 'Please select a complete address',
    path: ['address']
  })

// Cross-field validation
z.object({
  password: z.string().min(8),
  confirmPassword: z.string()
}).refine((data) => data.password === data.confirmPassword, {
  message: 'Passwords do not match',
  path: ['confirmPassword'] // Show error on confirmPassword field
})

Reactive Translations ​

Use computed() when schemas contain t():

typescript
const schema = computed(() => toTypedSchema(z.object({
  name: z.string().min(1, { message: t('errors.required') })
})))

const form = useForm({
  validationSchema: schema // Pass computed directly
})

Form Submission ​

Use form.handleSubmit() for validation + submission:

vue
<script setup>
const loading = ref(false)
const error = ref('')

const onSubmit = form.handleSubmit(async (values) => {
  loading.value = true
  error.value = ''

  try {
    const { $apiFetch } = useGetApiFetch()
    
    const result = await $apiFetch('/api/endpoint', {
      method: 'post',
      body: values
    })
    
    // Success - navigate or show message
    return navigateTo('/success')
  } catch (e: unknown) {
    if (isApiError<'/api/endpoint', 'post'>(e)) {
      error.value = e.data?.message || 'An error occurred'
    } else {
      error.value = getErrorMessage(e)
    }
  } finally {
    loading.value = false
  }
})
</script>

<template>
  <form @submit.prevent="onSubmit">
    <!-- fields -->
    
    <div v-if="error" class="text-sm text-destructive">
      {{ error }}
    </div>
    
    <UiButton type="submit" :disabled="loading">
      <Loader2 v-if="loading" class="animate-spin" />
      Submit
    </UiButton>
  </form>
</template>

Manual Validation ​

For multi-step forms or complex flows:

typescript
// Validate without submitting
const { valid, values } = await form.validate()

if (valid) {
  // Proceed to next step
  currentStep.value++
}

Type-Safe Validation Result ​

Use assertValidFormResult for discriminated union types:

typescript
import type { ApiBody } from '~/utils/apiTypes'

type FormData = Undefinedable<ApiBody<'/api/endpoint', 'post'>>

type ValidateResult = 
  | { valid: true; values: FormData }
  | { valid: false; values: null }

const validate = async (): Promise<ValidateResult> => {
  return assertValidFormResult(await form.validate())
}

// Usage in parent
const result = await formRef.value?.validate()
if (result?.valid) {
  // TypeScript knows result.values is non-null here
  const data = result.values
}

Multi-Step Forms ​

Validate each step independently:

vue
<script setup>
const step1FormRef = ref()
const step2FormRef = ref()
const currentStep = ref(1)

const validateStep = async (step: number) => {
  if (step === 1) {
    const result = await step1FormRef.value?.validate()
    return result?.valid ?? false
  }
  if (step === 2) {
    const result = await step2FormRef.value?.validate()
    return result?.valid ?? false
  }
  return true
}

const handleNext = async () => {
  const isValid = await validateStep(currentStep.value)
  if (isValid) {
    currentStep.value++
  }
}

const handleSubmit = async () => {
  // Validate all steps before final submission
  const allValid = await Promise.all([
    validateStep(1),
    validateStep(2)
  ])
  
  if (allValid.every(Boolean)) {
    // Submit
  }
}
</script>

<template>
  <div v-if="currentStep === 1">
    <Step1Form ref="step1FormRef" v-model="step1Data" />
    <UiButton @click="handleNext">Next</UiButton>
  </div>
  
  <div v-else-if="currentStep === 2">
    <Step2Form ref="step2FormRef" v-model="step2Data" />
    <UiButton @click="handleSubmit">Submit</UiButton>
  </div>
</template>

Dynamic Field Arrays ​

Use useFieldArray for repeating fields (tiers, owners, etc.):

vue
<script setup>
import { useFieldArray } from 'vee-validate'

const schema = computed(() => toTypedSchema(z.object({
  tiers: z.array(z.object({
    minMonths: z.coerce.number().min(0),
    accrualRate: z.coerce.number().min(0.00001)
  })).min(1, { message: 'At least one tier required' })
})))

const form = useForm({
  validationSchema: schema,
  initialValues: {
    tiers: [{ minMonths: 0, accrualRate: 0 }]
  }
})

const { fields, push, remove } = useFieldArray('tiers')

const addTier = () => {
  push({ minMonths: 0, accrualRate: 0 })
}

const removeTier = (index: number) => {
  if (fields.value.length > 1) {
    remove(index)
  }
}
</script>

<template>
  <div v-for="(field, index) in fields" :key="field.key">
    <UiFormField :name="`tiers[${index}].minMonths`" v-slot="{ componentField }">
      <UiFormGroup label="Min Months" required>
        <UiInput v-bind="componentField" type="number" />
      </UiFormGroup>
    </UiFormField>

    <UiFormField :name="`tiers[${index}].accrualRate`" v-slot="{ componentField }">
      <UiFormGroup label="Accrual Rate" required>
        <UiInput v-bind="componentField" type="number" step="0.01" />
      </UiFormGroup>
    </UiFormField>

    <UiButton 
      @click="removeTier(index)"
      :disabled="fields.length === 1"
    >
      Remove
    </UiButton>
  </div>

  <UiButton @click="addTier">
    Add Tier
  </UiButton>
</template>

Form + Model Sync ​

For child components with v-model:

vue
<script setup>
export type FormData = Undefinedable<ApiBody<'/api/endpoint', 'post'>>

const modelValue = defineModel<FormData>({ default: () => ({}) })

const schema = computed(() => toTypedSchema(z.object({
  name: z.string().min(1)
})))

const form = useForm({
  validationSchema: schema,
  initialValues: {
    name: ''
  }
})

// Sync form -> model
watch(() => form.values, (newValues) => {
  modelValue.value = { ...newValues } as FormData
}, { deep: true })

// Sync model -> form (when parent updates)
watch(() => modelValue.value, (newValue) => {
  form.setValues(newValue, false) // false = don't validate
}, { deep: true })
</script>

Helper Text & Tooltips ​

Add contextual help to fields:

vue
<UiFormGroup 
  label="Eligibility Months"
  required
  :helperText="t('tooltips.eligibilityMonths')"
>
  <UiInput v-bind="componentField" />
</UiFormGroup>

For multi-line help text:

vue
<UiFormGroup 
  label="Complex Field"
  :helperText="[
    'First line of help',
    'Second line of help'
  ]"
>
  <UiInput v-bind="componentField" />
</UiFormGroup>

Type Safety with API ​

Extract types directly from API schemas using ApiBody:

typescript
import type { ApiBody } from '~/utils/apiTypes'

// Type matches backend exactly
type EmployeeData = ApiBody<'/api/organizations/employees', 'post'>

// Make all fields optional for form (since we fill them gradually)
type FormData = Undefinedable<EmployeeData>

const modelValue = defineModel<FormData>()

Why Undefinedable?

  • API types have required fields
  • Forms start empty and fill gradually
  • Undefinedable<T> makes all properties optional for form state

See API Calls documentation for more about ApiBody and type safety.

Best Practices ​

Do's âś…

  • Use computed() for schemas with t() translations
  • Use assertValidFormResult for type-safe validation
  • Extract types from ApiBody<path, method> for type safety
  • Use useFieldArray for dynamic repeating fields
  • Set validateOnMount: false to avoid showing errors immediately
  • Use Undefinedable for form types when fields start empty

Don'ts ❌

  • Don't call t() directly in schema outside computed()
  • Don't validate on mount for new forms (users haven't typed yet)
  • Don't create custom types that diverge from API types
  • Don't use v-model directly on form fields (use componentField)

Quick Reference ​

NeedSolution
Basic formuseForm({ validationSchema, initialValues })
Field binding<UiFormField name="field" v-slot="{ componentField }">
Submit + validateform.handleSubmit(async (values) => { ... })
Manual validateawait form.validate()
Type-safe resultassertValidFormResult(await form.validate())
Dynamic fieldsuseFieldArray('fieldName')
Cross-field validationz.object(...).refine((data) => ...)
Reactive translationscomputed(() => toTypedSchema(z.object(...)))

Related: API Calls → | Internationalization →