Appearance
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 witht()translations - Use
assertValidFormResultfor type-safe validation - Extract types from
ApiBody<path, method>for type safety - Use
useFieldArrayfor dynamic repeating fields - Set
validateOnMount: falseto avoid showing errors immediately - Use
Undefinedablefor form types when fields start empty
Don'ts ❌
- Don't call
t()directly in schema outsidecomputed() - 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-modeldirectly on form fields (usecomponentField)
Quick Reference ​
| Need | Solution |
|---|---|
| Basic form | useForm({ validationSchema, initialValues }) |
| Field binding | <UiFormField name="field" v-slot="{ componentField }"> |
| Submit + validate | form.handleSubmit(async (values) => { ... }) |
| Manual validate | await form.validate() |
| Type-safe result | assertValidFormResult(await form.validate()) |
| Dynamic fields | useFieldArray('fieldName') |
| Cross-field validation | z.object(...).refine((data) => ...) |
| Reactive translations | computed(() => toTypedSchema(z.object(...))) |
Related: API Calls → | Internationalization →