Forms in Vue 3: Validation, Dirty State and Async Checks
AI generated
<v/>
{ }
Vue 3 · Form Validation · Dirty State · Async Checks
Forms in Vue 3:
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.

13 min read VeeValidate · Zod · useDebounceFn · Dirty State Vue 3.4+ · @vee-validate/zod

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.

11. FAQ: Forms in Vue 3, Validation, Dirty State and Async Checks

1What is dirty state in Vue 3?
Current value is not equal to the initial value. VeeValidate provides meta.dirty. Controls when error messages may first appear.
2Dirty vs. touched?
Dirty means the value has changed. Touched means the field was focused and left again (blur). Together they control the feedback timing.
3VeeValidate + Zod vs. VeeValidate alone?
Zod provides type safety with z.infer and can be shared between frontend and backend. VeeValidate alone does not have full TypeScript integration.
4Asynchronous validation in Vue 3?
useDebounceFn() (400 to 600ms) + AbortController for in-flight requests + meta.pending for the loading state in the template.
5When to show the error message?
First after the initial blur (touched=true). After that, on-change for immediate updates. VeeValidate's default behavior prevents aggressive early validation.
6Displaying server errors in the form?
setFieldError('fieldName', 'message') sets the server error directly on the corresponding field, appearing like a local validation error.
7Cross-field validation?
Zod .refine() at the object level with access to all fields. The error is automatically mapped to the correct field in VeeValidate via the Zod adapter.
8Resetting dirty state after submit?
resetForm() from VeeValidate after a successful submit. Resets all fields and clears dirty, touched and validation errors.
9Sharing a Zod schema between frontend and backend?
Move the schema into shared/schemas.ts and import it equally in the Vue frontend and in Nuxt server routes. No duplicate maintenance.
10Avoiding too many API requests on async checks?
useDebounceFn() with 400 to 600ms + AbortController + only checking once the formatting is already correct. Significantly reduces API load.