Vue 3 Form Validation Composable: 5 Core Patterns for Type-Safe Dynamic Forms

前端工程

Form Validation Pain Points: Why Build Your Own Composable

In 2026, Vue 3 Composition API has become the standard paradigm for form handling, yet validation logic scattered across components, complex async validation with debounce, dynamic field add/remove management, cross-field validation challenges, and chaotic error state management — these issues make form development the most frustrating part of frontend work. Building a custom form validation Composable, combined with Zod Schema for type safety, is the best path to solving these pain points.

Pain Point Manifestation Impact
Scattered validation logic Every form component repeats validation rules Code duplication, inconsistent rules
Complex async validation Username uniqueness checks, phone verification need debounce Request storms, poor UX
Dynamic form difficulty Runtime field add/remove, conditional visibility Validation state out of sync
Cross-field validation Password confirmation, date range checks Tightly coupled validation logic
Chaotic error state touched/dirty/valid states managed separately Inaccurate UI feedback

Core Concepts at a Glance

Concept Description
Composable Vue 3 composition function encapsulating reusable reactive logic
Form Validation Rule-based checking of user input to ensure data validity
Zod Schema TypeScript-first declarative schema validation library with type inference
Async Validation Validation requiring backend requests (e.g., uniqueness check), needs debounce
Dynamic Fields Form fields added/removed at runtime, requiring dynamic validation rule management
Error State Per-field error messages, touched status, dirty tracking
Dirty Check Determining whether a field value has been modified by the user
Submit State isSubmitting/isSubmitSuccessful states during form submission

Deep Analysis of 5 Key Challenges

Challenge 1: Validation Logic Reuse

Traditional approaches embed validation rules inside components, making them impossible to share. The same field validation rules (phone, email) are redefined in every form, and modifications are easily missed.

Challenge 2: Async Validation Debounce

Username uniqueness and email availability checks trigger requests on every keystroke without debounce. You need to distinguish between "validating in progress" and "validation failed" states, and debounce must not block user input.

Challenge 3: Dynamic Field Add/Remove

Dynamic forms (tag lists, multiple addresses) require runtime field management with independent validation per field. Removing a field must clean up its validation state; adding a field must initialize validation rules.

Challenge 4: Cross-Field Validation

Password confirmation, date ranges, and amount limits require accessing multiple field values simultaneously. Validation trigger timing must also be coordinated — any related field change should re-validate dependent fields.

Challenge 5: Form State Management

A single form field involves value, errors, touched, dirty, isValidating, and more. Manual management is error-prone. A unified reactive state container with auto-derived computed properties is essential.


Pattern 1: useValidation Basic Validation Composable

import { ref, reactive, computed, type Ref } from 'vue'

type Validator = (value: any) => string | true

interface FieldState {
  value: Ref<any>
  errors: string[]
  touched: boolean
  dirty: boolean
}

interface ValidationRule {
  validator: Validator
  message: string
}

export function useValidation<T extends Record<string, any>>(
  initialValues: T,
  rules: Record<keyof T, ValidationRule[]>
) {
  const fields = reactive<Record<string, FieldState>>({})
  const isValidating = ref(false)

  for (const key in initialValues) {
    fields[key] = {
      value: ref(initialValues[key]),
      errors: [],
      touched: false,
      dirty: false
    }
  }

  const validateField = (key: keyof T): boolean => {
    const field = fields[key as string]
    if (!field) return true

    const fieldRules = rules[key] || []
    const errors: string[] = []

    for (const rule of fieldRules) {
      const result = rule.validator(field.value.value)
      if (result !== true) {
        errors.push(rule.message)
      }
    }

    field.errors = errors
    return errors.length === 0
  }

  const validateAll = (): boolean => {
    let allValid = true
    for (const key in fields) {
      if (!validateField(key as keyof T)) {
        allValid = false
      }
    }
    return allValid
  }

  const touchField = (key: keyof T) => {
    const field = fields[key as string]
    if (field) {
      field.touched = true
    }
  }

  const markDirty = (key: keyof T) => {
    const field = fields[key as string]
    if (field) {
      field.dirty = field.value.value !== initialValues[key]
    }
  }

  const isValid = computed(() => {
    return Object.values(fields).every(
      (field) => field.errors.length === 0
    )
  })

  const isDirty = computed(() => {
    return Object.values(fields).some((field) => field.dirty)
  })

  const getValues = (): T => {
    const values = {} as T
    for (const key in fields) {
      values[key as keyof T] = fields[key].value.value
    }
    return values
  }

  const resetForm = () => {
    for (const key in initialValues) {
      const field = fields[key]
      field.value.value = initialValues[key]
      field.errors = []
      field.touched = false
      field.dirty = false
    }
  }

  return {
    fields,
    isValidating,
    isValid,
    isDirty,
    validateField,
    validateAll,
    touchField,
    markDirty,
    getValues,
    resetForm
  }
}

Pattern 2: Zod Schema-Driven Type-Safe Validation

import { ref, reactive, computed } from 'vue'
import { z, type ZodType, type ZodError } from 'zod'

interface ZodFieldState {
  value: any
  errors: string[]
  touched: boolean
  dirty: boolean
}

export function useZodForm<T extends ZodType<any>>(
  schema: T,
  initialValues: z.infer<T>
) {
  type FormValues = z.infer<T>

  const fields = reactive<Record<string, ZodFieldState>>({})
  const formErrors = ref<string[]>([])
  const isValidating = ref(false)

  for (const key in initialValues) {
    fields[key] = {
      value: initialValues[key],
      errors: [],
      touched: false,
      dirty: false
    }
  }

  const validateField = async (key: string): Promise<boolean> => {
    const field = fields[key]
    if (!field) return true

    try {
      const fieldSchema = schema.shape[key]
      if (fieldSchema) {
        await fieldSchema.parseAsync(field.value)
      }
      field.errors = []
      return true
    } catch (error) {
      const zodError = error as ZodError
      field.errors = zodError.errors.map((e) => e.message)
      return false
    }
  }

  const validateAll = async (): Promise<boolean> => {
    isValidating.value = true
    formErrors.value = []

    try {
      const values = getValues()
      await schema.parseAsync(values)
      for (const key in fields) {
        fields[key].errors = []
      }
      isValidating.value = false
      return true
    } catch (error) {
      const zodError = error as ZodError
      for (const issue of zodError.errors) {
        const path = issue.path[0]?.toString()
        if (path && fields[path]) {
          if (!fields[path].errors.includes(issue.message)) {
            fields[path].errors.push(issue.message)
          }
        } else {
          formErrors.value.push(issue.message)
        }
      }
      isValidating.value = false
      return false
    }
  }

  const isValid = computed(() => {
    return Object.values(fields).every(
      (field) => field.errors.length === 0
    ) && formErrors.value.length === 0
  })

  const getValues = (): FormValues => {
    const values = {} as FormValues
    for (const key in fields) {
      (values as any)[key] = fields[key].value
    }
    return values
  }

  const resetForm = () => {
    for (const key in initialValues) {
      fields[key].value = initialValues[key]
      fields[key].errors = []
      fields[key].touched = false
      fields[key].dirty = false
    }
    formErrors.value = []
  }

  return {
    fields,
    formErrors,
    isValidating,
    isValid,
    validateField,
    validateAll,
    getValues,
    resetForm
  }
}

const userSchema = z.object({
  username: z.string().min(3, 'Username must be at least 3 characters').max(20, 'Username must be at most 20 characters'),
  email: z.string().email('Please enter a valid email address'),
  password: z.string().min(8, 'Password must be at least 8 characters').regex(
    /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
    'Password must contain uppercase, lowercase, and numbers'
  )
})

type UserForm = z.infer<typeof userSchema>

Pattern 3: Async Validation with Debounce

import { ref, watch, type Ref } from 'vue'

interface AsyncValidationOptions {
  debounceMs?: number
  validateOnChange?: boolean
}

export function useAsyncValidation(
  fieldValue: Ref<string>,
  asyncValidator: (value: string) => Promise<string | null>,
  options: AsyncValidationOptions = {}
) {
  const { debounceMs = 500, validateOnChange = true } = options

  const isValidating = ref(false)
  const asyncError = ref<string | null>(null)
  let debounceTimer: ReturnType<typeof setTimeout> | null = null

  const executeValidation = async (value: string): Promise<boolean> => {
    if (!value) {
      asyncError.value = null
      return true
    }

    isValidating.value = true
    try {
      const error = await asyncValidator(value)
      asyncError.value = error
      return error === null
    } catch (err) {
      asyncError.value = 'Validation request failed, please retry'
      return false
    } finally {
      isValidating.value = false
    }
  }

  const debouncedValidate = (value: string) => {
    if (debounceTimer) {
      clearTimeout(debounceTimer)
    }
    debounceTimer = setTimeout(() => {
      executeValidation(value)
    }, debounceMs)
  }

  if (validateOnChange) {
    watch(fieldValue, (newValue) => {
      asyncError.value = null
      debouncedValidate(newValue)
    })
  }

  const validate = async (): Promise<boolean> => {
    if (debounceTimer) {
      clearTimeout(debounceTimer)
    }
    return executeValidation(fieldValue.value)
  }

  const reset = () => {
    if (debounceTimer) {
      clearTimeout(debounceTimer)
    }
    asyncError.value = null
    isValidating.value = false
  }

  return {
    isValidating,
    asyncError,
    validate,
    reset
  }
}

const checkUsernameUnique = async (username: string): Promise<string | null> => {
  const response = await fetch(`/api/check-username?username=${username}`)
  const data = await response.json()
  return data.exists ? 'This username is already taken' : null
}

Pattern 4: Dynamic Form Field Management

import { ref, reactive, computed } from 'vue'
import type { ZodType, ZodError } from 'zod'

interface DynamicFieldConfig {
  key: string
  label: string
  schema: ZodType<any>
  defaultValue: any
}

export function useDynamicForm() {
  const fieldConfigs = reactive<Map<string, DynamicFieldConfig>>(new Map())
  const fieldValues = reactive<Record<string, any>>({})
  const fieldErrors = reactive<Record<string, string[]>>({})
  const fieldTouched = reactive<Record<string, boolean>>({})

  const addField = (config: DynamicFieldConfig) => {
    fieldConfigs.set(config.key, config)
    fieldValues[config.key] = config.defaultValue
    fieldErrors[config.key] = []
    fieldTouched[config.key] = false
  }

  const removeField = (key: string) => {
    fieldConfigs.delete(key)
    delete fieldValues[key]
    delete fieldErrors[key]
    delete fieldTouched[key]
  }

  const addFieldGroup = (configs: DynamicFieldConfig[]) => {
    for (const config of configs) {
      addField(config)
    }
  }

  const removeFieldGroup = (keys: string[]) => {
    for (const key of keys) {
      removeField(key)
    }
  }

  const validateField = async (key: string): Promise<boolean> => {
    const config = fieldConfigs.get(key)
    if (!config) return true

    try {
      await config.schema.parseAsync(fieldValues[key])
      fieldErrors[key] = []
      return true
    } catch (error) {
      const zodError = error as ZodError
      fieldErrors[key] = zodError.errors.map((e) => e.message)
      return false
    }
  }

  const validateAll = async (): Promise<boolean> => {
    let allValid = true
    for (const key of fieldConfigs.keys()) {
      if (!(await validateField(key))) {
        allValid = false
      }
    }
    return allValid
  }

  const touchField = (key: string) => {
    fieldTouched[key] = true
  }

  const isValid = computed(() => {
    return Object.values(fieldErrors).every(
      (errors) => errors.length === 0
    )
  })

  const fieldCount = computed(() => fieldConfigs.size)

  const resetAll = () => {
    for (const [key, config] of fieldConfigs) {
      fieldValues[key] = config.defaultValue
      fieldErrors[key] = []
      fieldTouched[key] = false
    }
  }

  return {
    fieldConfigs,
    fieldValues,
    fieldErrors,
    fieldTouched,
    isValid,
    fieldCount,
    addField,
    removeField,
    addFieldGroup,
    removeFieldGroup,
    validateField,
    validateAll,
    touchField,
    resetAll
  }
}

Pattern 5: End-to-End Form Submission and Error Handling

import { ref, computed, type Ref } from 'vue'
import type { ZodType } from 'zod'

interface SubmitOptions<T> {
  schema: ZodType<T>
  onSubmit: (values: T) => Promise<void>
  onSuccess?: () => void
  onError?: (error: unknown) => void
}

interface FormSubmitState {
  isSubmitting: boolean
  isSubmitSuccessful: boolean
  submitCount: number
  serverErrors: Record<string, string[]>
  rootError: string | null
}

export function useFormSubmit<T extends Record<string, any>>(
  getValues: () => T,
  options: SubmitOptions<T>
) {
  const state = ref<FormSubmitState>({
    isSubmitting: false,
    isSubmitSuccessful: false,
    submitCount: 0,
    serverErrors: {},
    rootError: null
  })

  const handleSubmit = async () => {
    state.value.isSubmitting = true
    state.value.rootError = null
    state.value.serverErrors = {}
    state.value.submitCount++

    try {
      const values = getValues()
      const validated = await options.schema.parseAsync(values)

      await options.onSubmit(validated)

      state.value.isSubmitSuccessful = true
      options.onSuccess?.()
    } catch (error: unknown) {
      state.value.isSubmitSuccessful = false

      if (error && typeof error === 'object' && 'errors' in error) {
        const zodLike = error as { errors: Array<{ path: (string | number)[]; message: string }> }
        for (const issue of zodLike.errors) {
          const path = issue.path[0]?.toString()
          if (path) {
            if (!state.value.serverErrors[path]) {
              state.value.serverErrors[path] = []
            }
            state.value.serverErrors[path].push(issue.message)
          } else {
            state.value.rootError = issue.message
          }
        }
      } else if (error instanceof Error) {
        state.value.rootError = error.message
      } else {
        state.value.rootError = 'Submission failed, please try again later'
      }

      options.onError?.(error)
    } finally {
      state.value.isSubmitting = false
    }
  }

  const canSubmit = computed(() => {
    return !state.value.isSubmitting
  })

  const clearServerErrors = () => {
    state.value.serverErrors = {}
    state.value.rootError = null
  }

  const resetSubmitState = () => {
    state.value = {
      isSubmitting: false,
      isSubmitSuccessful: false,
      submitCount: 0,
      serverErrors: {},
      rootError: null
    }
  }

  return {
    submitState: state,
    canSubmit,
    handleSubmit,
    clearServerErrors,
    resetSubmitState
  }
}

Pitfall Guide

Scenario Wrong Approach Right Approach
Validation timing ❌ Only validate all fields on submit ✅ Validate individual fields on blur, all fields on submit
Async debounce ❌ Send request on every input event ✅ Use debounce, only send after 500ms of inactivity
Dynamic field cleanup ❌ Only remove UI, not validation state ✅ Synchronously clean up fieldValues/fieldErrors/fieldTouched
Cross-field validation ❌ Hardcode other field references in single-field validator ✅ Use Zod's refine/superRefine for declarative cross-field validation
Error message override ❌ Server errors directly overwrite client validation errors ✅ Distinguish clientErrors and serverErrors, prioritize client errors

Error Troubleshooting

Error Message Cause Solution
Cannot read properties of undefined (reading 'value') fields[key] not initialized Ensure all fields have initial values registered in useValidation
z.parseAsync is not a function Non-Zod Schema object passed Check that schema parameter is a Zod type like z.object()
Validate function returned undefined Validator didn't return true or error message Validator must return true (pass) or string (error message)
Maximum call stack size exceeded Watch loop triggering validation Add conditions to prevent validation results from re-triggering watch
debounce is not cleared on unmount Timer not cleaned up when component unmounts Clear all debounceTimers in onUnmounted
Async validation result is stale Value changed during debounce but old request returned Use abort controller or compare current value with request value
Field errors not cleared after reset resetForm didn't clear errors arrays Ensure resetForm iterates all fields and resets errors to []
Type 'string' is not assignable to type Zod inferred type doesn't match form value type Use z.infer<typeof schema> to ensure type consistency
Cross-field validation not triggered Only validated current field without triggering related fields Password confirmation etc. requires watching both fields and linking validate
Server errors not showing in UI serverErrors and clientErrors merge logic is wrong Unify error display entry, deduplicate when merging and maintain order

Advanced Optimization Tips

1. Validation Result Caching

import { ref, type Ref } from 'vue'

interface CacheEntry {
  value: string
  result: string | null
  timestamp: number
}

export function useValidationCache(
  validator: (value: string) => Promise<string | null>,
  ttlMs = 30000
) {
  const cache = new Map<string, CacheEntry>()

  const validate = async (value: string): Promise<string | null> => {
    const cached = cache.get(value)
    if (cached && Date.now() - cached.timestamp < ttlMs) {
      return cached.result
    }

    const result = await validator(value)
    cache.set(value, { value, result, timestamp: Date.now() })
    return result
  }

  const invalidate = (value?: string) => {
    if (value) {
      cache.delete(value)
    } else {
      cache.clear()
    }
  }

  return { validate, invalidate }
}

2. Form State Persistence

import { watch, onMounted } from 'vue'
import type { Ref } from 'vue'

export function useFormPersistence(
  formKey: string,
  fieldValues: Ref<Record<string, any>>,
  options: { storage?: 'localStorage' | 'sessionStorage'; debounceMs?: number } = {}
) {
  const { storage = 'localStorage', debounceMs = 1000 } = options
  const storageKey = `form-draft:${formKey}`
  let timer: ReturnType<typeof setTimeout> | null = null

  const save = () => {
    const store = window[storage]
    store.setItem(storageKey, JSON.stringify(fieldValues.value))
  }

  const restore = () => {
    const store = window[storage]
    const saved = store.getItem(storageKey)
    if (saved) {
      try {
        const parsed = JSON.parse(saved)
        Object.assign(fieldValues.value, parsed)
      } catch {}
    }
  }

  const clear = () => {
    const store = window[storage]
    store.removeItem(storageKey)
  }

  onMounted(restore)

  watch(
    fieldValues,
    () => {
      if (timer) clearTimeout(timer)
      timer = setTimeout(save, debounceMs)
    },
    { deep: true }
  )

  return { restore, clear }
}

3. Conditional Validation Rules

import { z } from 'zod'

export function conditionalSchema<T>(
  condition: () => boolean,
  trueSchema: z.ZodType<T>,
  falseSchema: z.ZodType<T>
) {
  return z.custom<T>((value) => {
    const schema = condition() ? trueSchema : falseSchema
    return schema.safeParse(value).success
  })
}

const formSchema = z.object({
  userType: z.enum(['personal', 'enterprise']),
  companyName: conditionalSchema(
    () => true,
    z.string().min(2, 'Company name must be at least 2 characters'),
    z.string().optional()
  ),
  idNumber: conditionalSchema(
    () => true,
    z.string().regex(/^\d{18}$/, 'Please enter 18-digit ID number'),
    z.string().regex(/^[A-Z0-9]{10}$/, 'Please enter unified social credit code')
  )
})

Comparative Analysis

Dimension Custom Composable VeeValidate FormKit Vuelidate
Learning curve Medium (need reactive understanding) High (rich API, many concepts) Medium (declarative config) Low (decorator style)
Bundle size <5KB (implement as needed) ~13KB (core) ~32KB (full) ~6KB
TypeScript support Native (Zod inference) Good (requires generic config) Good Fair
Async validation Custom debounce + cache Built-in debounce Built-in rules Manual implementation
Dynamic fields Full control useFieldArray Schema-driven $model binding
Cross-field validation Zod refine yup.refine Rule config $each+$v
State management Fine-grained reactive useForm unified Node tree $v object
Customization flexibility Very high Medium (API constrained) Medium High
Vue 3 compatibility Native v4 native Native @vuelidate/core
Best for Strong customization needs Complex form projects Rapid development Simple validation

Summary and Outlook

The core of Vue 3 form validation Composables lies in extracting validation logic from components and implementing declarative validation through reactive state management. The 5 core patterns — basic validation Composable, Zod Schema type safety, async validation with debounce, dynamic field management, and end-to-end submission handling — cover the complete form spectrum from simple to complex.

In 2026, Zod has become the standard schema validation library in the TypeScript ecosystem. Combined with Vue 3 Composables, it enables truly type-safe form development. As Vue Vapor Mode advances, form validation performance will further improve. Start with the useValidation basic pattern, gradually introduce Zod Schema and async validation, and ultimately build a custom form validation solution tailored to your team.


Try these browser-local tools — no sign-up required →

#Vue3表单验证#Composable#表单校验#异步验证#动态表单#2026#前端工程