Validation, Dirty State and Async Checks
A form that only tells the user after submitting that the email address is already taken creates avoidable frustration. Professional form implementation in Vue 3 means detecting dirty state, showing validation at the right moment, and integrating asynchronous checks cleanly with debounce and a loading state.
Table of Contents
- 1. Why dirty state is more than a technical detail
- 2. Implementing dirty state and pristine state manually
- 3. VeeValidate: form state from a single source
- 4. A Zod schema for type-safe validation
- 5. When do I show error messages? The feedback timing
- 6. Asynchronous validation: API checks with debounce
- 7. Submit handling: pending, success and error
- 8. Cross-field validation and dependent fields
- 9. Comparing validation approaches
- 10. Summary
- 11. FAQ
1. Why dirty state is more than a technical detail
The dirty state of a form field indicates whether the user has changed the original value. It is not an academic concept; it has a direct impact on user experience: a form that shows error messages for empty required fields right after loading feels aggressive and disorients the user. Only once a field has reached dirty state, meaning it has actually been touched and changed by the user, should the validation message appear. The opposite of dirty state is pristine state: the field still holds its initial value, and the user has not touched it yet.
Beyond the pure UX aspect, dirty state is also technically valuable: it makes it possible to show "You have unsaved changes" dialogs when leaving the page only when changes actually exist. It helps send only the changed fields to the API instead of the entire form. In Vue 3 you either implement dirty state manually or use a library like VeeValidate that ships it out of the box. Both approaches have their place, and this article covers both in a practical context.
2. Implementing dirty state and pristine state manually
A manual dirty state implementation in Vue 3 is based on comparing the current field value against the initial value. You store the initial value in a separate ref that is set on mount and updated on form reset. Dirty state is then a computed() that compares the current ref against the stored initial value. For complex objects this comparison needs to be deep, which you solve with JSON.stringify() or a deep-equal function.
The touched-state concept complements dirty state: a field is "touched" once the user has focused it and left it again, regardless of whether they changed anything. VeeValidate uses both concepts: meta.dirty for value changes and meta.touched for blur events. This enables fine-grained feedback timing: show error messages after blur (touched), but only if the user has actually entered something (dirty). This combination forms the backbone of any professional Vue 3 form validation.
// composables/useFieldState.js: manual dirty and touched state tracking
import { ref, computed } from 'vue'
export function useFieldState(initialValue) {
const value = ref(initialValue)
const initialSnapshot = ref(initialValue)
const touched = ref(false)
// Dirty: current value differs from initial value
const isDirty = computed(() => {
return JSON.stringify(value.value) !== JSON.stringify(initialSnapshot.value)
})
// Pristine is the inverse of dirty
const isPristine = computed(() => !isDirty.value)
// Mark as touched when user leaves the field
function onBlur() {
touched.value = true
}
// Reset field to initial value and clear state
function reset() {
value.value = initialSnapshot.value
touched.value = false
}
// Update initial snapshot (e.g. after successful save)
function markAsSaved() {
initialSnapshot.value = value.value
}
return { value, isDirty, isPristine, touched, onBlur, reset, markAsSaved }
}
3. VeeValidate: form state from a single source
VeeValidate is the most widely used validation library for Vue 3 forms. Its Composition API, useForm(), useField() and the Form/Field components, delivers dirty state, touched state, validation status and submit handling as fully reactive refs. The meta object of each field contains dirty, touched, valid and pending (for ongoing async validations). useForm().meta aggregates these states for the entire form.
Integrating VeeValidate into Vue 3's Composition API style is seamless. You define the form with useForm({ validationSchema }), define fields with useField('fieldName'), and bind value, errorMessage and handleChange/handleBlur directly to the template. VeeValidate automatically takes care of dirty state tracking, validation timing (on-change vs. on-blur) and the submit lifecycle. For new Vue 3 projects, VeeValidate with Zod is the recommended combination for professional form validation.
4. A Zod schema for type-safe validation
Zod is a TypeScript-first schema validation library that combines ideally with VeeValidate. A Zod schema defines the type and the validation rules in a single, type-safe expression. The schema serves simultaneously as a TypeScript type derivation via z.infer<typeof schema> and as a runtime validator. With the @vee-validate/zod adapter, the Zod schema is used directly as a VeeValidate validation schema, without manual conversion.
The big advantage of Zod in Vue 3 forms: the same schema can be used on the server for API validation (for example in a Nuxt server route), so frontend and backend validation rules never drift apart. If the email length is limited to 254 characters on the server, it is automatically limited to 254 characters in the frontend too, thanks to the shared Zod schema. This DRY principle in form validation is one of the strongest arguments for Zod in the Vue 3 ecosystem.
// composables/useRegistrationForm.js: VeeValidate with Zod schema
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { z } from 'zod'
// Define schema once, use for both frontend and backend validation
const registrationSchema = z.object({
email: z
.string()
.email('Ungültige E-Mail-Adresse.')
.max(254, 'E-Mail-Adresse zu lang.'),
password: z
.string()
.min(8, 'Mindestens 8 Zeichen erforderlich.')
.regex(/[A-Z]/, 'Mindestens ein Großbuchstabe erforderlich.')
.regex(/[0-9]/, 'Mindestens eine Zahl erforderlich.'),
passwordConfirm: z.string(),
}).refine((data) => data.password === data.passwordConfirm, {
message: 'Passwörter stimmen nicht überein.',
path: ['passwordConfirm'],
})
export type RegistrationForm = z.infer<typeof registrationSchema>
export function useRegistrationForm() {
const { handleSubmit, meta, setFieldError } = useForm({
validationSchema: toTypedSchema(registrationSchema),
})
return { handleSubmit, meta, setFieldError }
}
5. When do I show error messages? The feedback timing
The timing of error messages is one of the most important UX decisions in form validation in Vue 3. Errors shown too early disrupt the input flow; errors shown too late frustrate users at submit time. The most proven strategy combines several triggers: error messages first appear when the user leaves the field (on-blur) and dirty state is present. After that, they are updated immediately on every further change (on-change), so the user gets direct feedback on whether their correction succeeded.
VeeValidate implements this pattern by default: validation runs on-change and on-blur, but error messages are only shown after the first blur event or after a submit attempt. This behavior can be adjusted via the validateOnMount, validateOnChange and validateOnBlur options. For fields that are frequently filled in correctly (e.g. first name), later validation (only on-blur) is better. For critical fields like passwords with security requirements, early validation (on-change, as soon as dirty) is more useful, because the user immediately sees which requirements they have already met.
6. Asynchronous validation: API checks with debounce
Asynchronous validations, the most common use case being checking whether an email address is already registered, are the most complex variant of form validation in Vue 3. Without debounce, every keystroke would trigger an API request, which burdens the API and validates unfinished input. With useDebounceFn() from VueUse, you wait a defined amount of time after the last keystroke before the request is sent. 400 to 600 ms is a good starting value for async validations.
While the asynchronous check runs, show the user a loading state, either a spinner inside the input field or a neutral status message like "Checking...". VeeValidate automatically sets meta.pending to true while an async validator is running. In Zod, async validations can be added via z.string().refine(async (email) => { ... }). An important detail: async validations should be aborted when the user leaves the field or a new check starts; an AbortController reliably cancels in-flight fetch requests.
// composables/useEmailValidation.js: async email uniqueness check with debounce
import { ref } from 'vue'
import { useDebounceFn } from '@vueuse/core'
export function useEmailUniqueCheck() {
const isChecking = ref(false)
const lastCheckedEmail = ref('')
let abortController = null
const checkEmailUnique = useDebounceFn(async (email, setError, clearError) => {
// Skip check if email hasn't changed or is invalid format
if (email === lastCheckedEmail.value || !email.includes('@')) return
// Abort previous in-flight request
if (abortController) abortController.abort()
abortController = new AbortController()
isChecking.value = true
lastCheckedEmail.value = email
try {
const response = await fetch(`/api/auth/check-email?email=${encodeURIComponent(email)}`, {
signal: abortController.signal,
})
const { available } = await response.json()
if (!available) {
setError('email', 'Diese E-Mail-Adresse ist bereits registriert.')
} else {
clearError('email')
}
} catch (err) {
if (err.name !== 'AbortError') {
// Network error, do not block form submission
console.warn('[EmailCheck] API nicht erreichbar:', err.message)
}
} finally {
isChecking.value = false
}
}, 500)
return { checkEmailUnique, isChecking }
}
7. Submit handling: pending, success and error
Submit handling for a Vue 3 form must cleanly manage three states: the running submit (pending), the success case (success) and the error case (error), including server-side validation errors that must be played back as field errors. VeeValidate's handleSubmit() wrapper ensures the form is fully valid before the submit callback is invoked. Without this safeguard, you have to trigger and check validation manually.
Server-side validation errors, such as "This combination of name and company name already exists", are errors that only the server can detect and that must be played back as field errors after submit. VeeValidate's setFieldError() sets such external errors directly on individual fields. Important: after a successful submit, dirty state should be reset (resetForm()), so the "You have unsaved changes" guard does not fire unnecessarily on the next navigation. Many implementations forget this detail, which confuses users.
8. Cross-field validation and dependent fields
Cross-field validation, rules that affect several fields at once, is one of the more demanding requirements in form validation in Vue 3. The classic example is "password" and "confirm password", which must match. Zod solves this with .refine() at the object level, which has access to all field values. In VeeValidate, cross-field validation can be used automatically via the Zod adapter; the error is correctly mapped to the passwordConfirm field, not to the entire form.
Another common cross-field requirement: date range validation, where startDate must come before endDate. Zod's superRefine() counterpart with multiple issues lets you set errors on several fields at once. In forms without Zod, cross-field validation can be implemented as a watch effect: whenever one of the dependent fields changes, validation of the other field is re-run. This pattern works reliably but requires more manual wiring than the Zod approach.
9. Comparing validation approaches in Vue 3
Choosing the right approach for form validation in Vue 3 depends on the requirements for type safety, complexity and dependencies. Here is a direct comparison of the most common strategies:
| Approach | Dirty State | Async Support | Type Safety |
|---|---|---|---|
| Manual (ref + watch) | Implement yourself | Manual with debounce | None |
| VeeValidate alone | meta.dirty built in | meta.pending built in | Limited |
| VeeValidate + Zod | meta.dirty built in | Zod async refine | Full (z.infer) |
| Formkit | Built in | Built in | Limited |
| Reactive object + native HTML5 | Not present | Manual | None |
For new Vue 3 projects with TypeScript, VeeValidate + Zod is the clear recommendation. The overhead of the libraries is minimal (VeeValidate ~7kB, Zod ~12kB gzipped), while the gain in type safety, dirty state tracking and async validation support is substantial. For small forms with 2 to 3 fields without async checks, a manual implementation with ref and watch can be enough and saves dependencies.
Mironsoft
Vue 3 form development with VeeValidate, Zod and optimal UX
Forms that do not frustrate users?
We build Vue 3 forms with professional dirty state tracking, type-safe validation via Zod, asynchronous API checks and the right feedback timing, for user experiences that feel intuitive.
Form audit
Analyze existing forms for dirty state, validation timing and async checks
Zod schema
Shared validation schemas for frontend and backend, no more duplicate maintenance
Async validation
Debounced API checks with loading state, AbortController and clean error handling
10. Summary
Professional form validation in Vue 3 covers more than checking required fields. Dirty state and touched state control when error messages appear, preventing aggressive early validation. VeeValidate delivers these states out of the box and integrates seamlessly with type-safe schemas via the Zod adapter. Zod schemas can be shared between frontend and backend, so validation rules never drift apart. Asynchronous checks with useDebounceFn and AbortController verify API constraints efficiently without overloading the API.
Feedback timing, on-blur for the first display and on-change for updates, is no small matter; it is the difference between a form that feels intuitive and one that frustrates users. Submit handling with server error playback via setFieldError() and resetting dirty state after a successful submit round out the picture. This combination makes any Vue 3 form validation production ready.
Vue 3 Forms: The Essentials at a Glance
Dirty State
Current value is not equal to the initial value. VeeValidate provides meta.dirty and meta.touched out of the box. Controls when error messages appear.
Zod Integration
Wire a Zod schema into VeeValidate via toTypedSchema(). Same schema for frontend and backend validation, no DRY violation.
Async Checks
useDebounceFn() for a 400 to 600ms delay. AbortController cancels in-flight requests. meta.pending shows the loading state in the template.
Submit Lifecycle
handleSubmit() validates before submit. Server errors via setFieldError(). After success, resetForm() for a clean dirty state.