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.
Recommended Online Tools
- Vue SFC Playground — Write and debug Vue 3 composition functions online
- Zod Playground — Test Zod Schema validation rules online
- Regex101 — Online regex debugging to aid validation rule authoring
- JSON Schema Validator — Schema validation comparison tool
- ToolsKu JSON Formatter — Format API response JSON online
Try these browser-local tools — no sign-up required →