Prompt
How do I validate complex forms in Vue.js?
Latest observation
Validating complex forms in Vue 3—especially those involving multi-step wizards, dynamic array fields (like adding/removing line items), or deeply nested object schemas—is best handled using standard headless validation libraries like VeeValidate paired with Zod schema validation.
Instead of writing manual watch handlers or bloated v-if conditionals for error messages, schema-driven validation keeps logic decoupled from rendering and provides strict TypeScript safety.
Recommended Architecture: VeeValidate + Zod For complex forms, VeeValidate v4 coupled with Zod is the standard approach in the Vue ecosystem. VeeValidate manages form state, field tracking, dirty states, and submit handling, while Zod handles schema definitions, deep type inference, and validation rules.
- Defining the Schema with Zod Zod allows you to express complex validation structures, such as dynamic arrays, conditional fields, and custom refinements:
TypeScript import { z } from 'zod';
export const userProfileSchema = z.object({ fullName: z.string().min(2, 'Name must be at least 2 characters'), email: z.string().email('Invalid email address'),
// Conditional section isBusinessAccount: z.boolean(), vatNumber: z.string().optional(),
// Array of nested objects (e.g., dynamic addresses) addresses: z.array( z.object({ street: z.string().min(1, 'Street is required'), city: z.string().min(1, 'City is required'), zipCode: z.string().regex(/^\d{5}$/, 'ZIP code must be 5 digits'), }) ).min(1, 'At least one address is required'), }).refine((data) => { if (data.isBusinessAccount && !data.vatNumber) { return false; } return true; }, { message: 'VAT number is required for business accounts', path: ['vatNumber'], });
export type UserProfileForm = z.infer<typeof userProfileSchema>; 2. Setting Up the Vue Component Using VeeValidate's useForm and useFieldArray composables, you can easily connect the Zod schema to your UI state.
Code snippet
<script setup lang="ts"> import { useForm, useFieldArray } from 'vee-validate'; import { toTypedSchema } from '@vee-validate/zod'; import { userProfileSchema } from './schema'; // Convert Zod schema to VeeValidate typed schema const { handleSubmit, errors, defineField, values } = useForm({ validationSchema: toTypedSchema(userProfileSchema), initialValues: { fullName: '', email: '', isBusinessAccount: false, vatNumber: '', addresses: [{ street: '', city: '', zipCode: '' }], }, }); // Bind form input fields const [fullName, fullNameProps] = defineField('fullName'); const [email, emailProps] = defineField('email'); const [isBusinessAccount] = defineField('isBusinessAccount'); const [vatNumber, vatProps] = defineField('vatNumber'); // Manage dynamic field arrays (e.g., adding/removing addresses) const { fields: addressFields, push: addAddress, remove: removeAddress } = useFieldArray('addresses'); const onSubmit = handleSubmit((formValues) => { console.log('Valid Form Data Submitted:', formValues); }); </script> <template> <form @submit="onSubmit"> <!-- User Info --> <div> <label>Full Name</label> <input v-model="fullName" v-bind="fullNameProps" /> <span class="error">{{ errors.fullName }}</span> </div><div>
<label>Email</label>
<input v-model="email" v-bind="emailProps" type="email" />
<span class="error">{{ errors.email }}</span>
</div>
<!-- Conditional Field -->
<div>
<label>
<input type="checkbox" v-model="isBusinessAccount" />
Business Account?
</label>
</div>
<div v-if="values.isBusinessAccount">
<label>VAT Number</label>
<input v-model="vatNumber" v-bind="vatProps" />
<span class="error">{{ errors.vatNumber }}</span>
</div>
<!-- Dynamic Array Fields -->
<h3>Addresses</h3>
<div v-for="(field, index) in addressFields" :key="field.key" class="address-group">
<div>
<label>Street</label>
<input v-model="values.addresses[index].street" />
<span class="error">{{ errors[`addresses[${index}].street`] }}</span>
</div>
<div>
<label>City</label>
<input v-model="values.addresses[index].city" />
<span class="error">{{ errors[`addresses[${index}].city`] }}</span>
</div>
<div>
<label>ZIP Code</label>
<input v-model="values.addresses[index].zipCode" />
<span class="error">{{ errors[`addresses[${index}].zipCode`] }}</span>
</div>
<button type="button" @click="removeAddress(index)" v-if="addressFields.length > 1">
Remove Address
</button>
</div>
<button type="button" @click="addAddress({ street: '', city: '', zipCode: '' })">
+ Add Address
</button>
<button type="submit">Submit Form</button>
</form>
</template>
Core Best Practices for Complex Forms
Keep UI Agnostic: Use headless validators like VeeValidate or Vuelidate. Avoid binding validation directly into UI components so you can swap or restyle inputs without breaking form rules.
Schema Decoupling: Keep Zod or Yup schemas in separate TypeScript files (schema.ts). This allows schemas to be shared across components or reused on the server for backend validation.
Dynamic Field Arrays: Always use dedicated field array primitives (useFieldArray) rather than mutating array states directly. This ensures tracked keys (field.key) stay consistent across drag-and-drop or insertions.
Asynchronous Validation: For real-time backend checks (such as checking username or email availability), attach .refine() with async functions inside your Zod schema or use VeeValidate's built-in async rules.
Observation history
See 2 earlier observations
Create a free Obsurfable account to unlock historical responses, model comparisons, and deeper brand intelligence.
How did Obsurfable measure this prompt?
Obsurfable records AI answers to buyer-style prompts in its research corpus (3 observations for this page). Metrics are distributions over observations, not a single static ranking.
Which AI systems does Obsurfable collect answers from?
OpenAI, ChatGPT, Google, Gemini, Google AI Mode, Anthropic, Claude, Perplexity, Grok, DeepSeek, Mistral, Copilot, and Meta AI.