Prompt
How do I create reusable forms in Vue.js?
Latest observation
To create reusable forms in Vue 3, you combine custom input primitives with Form Wrapper components, utilizing defineModel() for clean two-way data binding.When managing large, complex, or dynamic forms, standardizing around these core patterns provides the required structure.Step 1: Create a Reusable Base Input ComponentStart by abstracting your standard form controls (text fields, selects, checkboxes) into base UI primitives. The defineModel() macro handles the prop binding and updating logic automatically. BaseInput.vue:Code snippet<script setup lang="ts">
import { useId } from 'vue'
const props = defineProps<{
- label: string
- type?: string
- error?: string
- placeholder?: string
}>()
// Creates a reactive model bound to update:modelValue automatically
const model = defineModel<string | number>()
// Vue 3.5+ utility for generating accessible IDs automatically
const inputId = useId()
</script>
<template>
<div class="field-group">
<label :for="inputId">{{ label }}</label>
<input
:id="inputId"
v-model="model"
:type="type || 'text'"
:placeholder="placeholder"
:class="{ 'has-error': !!error }"
/>
<span v-if="error" class="error-message">{{ error }}</span>
</div>
</template>
- Step 2: Build a Reusable Form Group ComponentWhen forms repeat across a project—such as user addresses, billing profiles, or login credentials—encapsulate that specific set of inputs into a self-contained component. Pass the object via v-model and wrap child properties using computed setters. AddressForm.vue:Code snippet<script setup lang="ts">
import { computed } from 'vue'
import BaseInput from './BaseInput.vue'
export interface Address {
- street: string
- city: string
- zip: string
}
const props = defineProps<{
- errors?: Record<string, string>
}>()
// Binds the entire Address object to parent state
const model = defineModel<Address>({
- default: () => ({ street: '', city: '', zip: '' })
})
// Mutates properties safely without breaking single-direction prop flow
- function updateField<K extends keyof Address>(key: K, val: Address[K]) {
- model.value = { ...model.value, [key]: val }
}
const street = computed({
- get: () => model.value.street,
- set: (val) => updateField('street', val)
})
const city = computed({
- get: () => model.value.city,
- set: (val) => updateField('city', val)
})
const zip = computed({
- get: () => model.value.zip,
- set: (val) => updateField('zip', val)
})
</script>
<template>
<fieldset class="address-group">
<legend>Address Details</legend>
<BaseInput
v-model="street"
label="Street Address"
:error="errors?.street"
/>
<BaseInput
v-model="city"
label="City"
:error="errors?.city"
/>
<BaseInput
v-model="zip"
label="ZIP / Postal Code"
:error="errors?.zip"
/>
</fieldset>
</template>
- Step 3: Implement Schema/Composition for Validation LogicIsolate state management, submit handling, and schema validation (such as Zod or Yup) into custom Composables. This keeps UI templates concise and cleanly separated from business logic. useForm.ts:TypeScriptimport { ref, reactive } from 'vue'
export function useForm<T extends Record<string, any>>(initialValues: T) {
const values = reactive<T>({ ...initialValues })
const errors = reactive<Record<string, string>>({})
const isSubmitting = ref(false)
const resetForm = () => {
Object.assign(values, initialValues)
Object.keys(errors).forEach((key) => delete errors[key])
}
return {
values,
errors,
isSubmitting,
resetForm
}
}
- Step 4: Consume the Form Components in a Page ViewNow, compose the base inputs and form groups within the parent component view.CheckoutView.vue:Code snippet<script setup lang="ts">
import { useForm } from './useForm'
import BaseInput from './BaseInput.vue'
import AddressForm from './AddressForm.vue'
const { values, errors, isSubmitting } = useForm({
- fullName: '',
- shippingAddress: { street: '', city: '', zip: '' }
})
const handleSubmit = async () => {
isSubmitting.value = true
try {
// Send values to an external endpoint or API payload
console.log('Submitting payload:', values)
} finally {
isSubmitting.value = false
}
}
</script>
<template>
<form @submit.prevent="handleSubmit">
<h2>Checkout</h2>
<BaseInput
v-model="values.fullName"
label="Full Name"
:error="errors.fullName"
/>
<!-- Reusable sub-form module -->
<AddressForm
v-model="values.shippingAddress"
:errors="errors"
/>
<button type="submit" :disabled="isSubmitting">
Submit Order
</button>
</form>
</template>
Best Practices SummaryUse defineModel() for Custom Inputs: Simplifies update:modelValue boilerplate code when creating custom inputs in modern Vue 3. Avoid Mutating Deep Props Directly: When passing complex objects (e.g., v-model="userAddress"), emit an updated clone of the object or use computed setters rather than mutating properties directly on child components. Use Headless Form Libraries for Scale: For production applications with extensive field validation, dynamic schemas, and complex accessibility (a11y) rules, consider leveraging headless form solutions like VeeValidate, FormKit, or Vueform.
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.