Form Handling in Vue
Forms are the primary way users send data to an application. Vue 3's v-model directive creates two-way bindings between form inputs and reactive state, eliminating the manual event handling that plain JavaScript requires. The pattern is consistent: bind state to inputs, handle submission, show feedback.
v-model for Two-Way Binding
v-model on an input synchronizes the input's value with a reactive variable. When the user types, the ref updates. When the ref changes programmatically, the input reflects it.
<script setup lang="ts">
const name = ref('')
</script>
<template>
<div>
<input v-model="name" type="text" placeholder="Your name" />
<p>Hello, {{ name || '...' }}</p>
</div>
</template>
Under the hood, v-model on an <input> expands to:
<input :value="name" @input="name = $event.target.value" />
This means v-model is syntactic sugar for a prop binding plus an event listener. Understanding this helps when building custom form components.
Form Input Types
Text Input
<script setup lang="ts">
const email = ref('')
</script>
<template>
<label>
Email
<input v-model="email" type="email" placeholder="user@example.com" />
</label>
</template>
Textarea
v-model works the same way on <textarea>. Do not use interpolation inside the tag.
<script setup lang="ts">
const bio = ref('')
</script>
<template>
<label>
Bio
<textarea v-model="bio" rows="4" placeholder="Tell us about yourself"></textarea>
</label>
<!-- NOT <textarea>{{ bio }}</textarea> -->
</template>
Select
<script setup lang="ts">
const selectedRole = ref('')
const roles = ['Admin', 'Editor', 'Viewer']
</script>
<template>
<label>
Role
<select v-model="selectedRole">
<option value="" disabled>Choose a role</option>
<option v-for="role in roles" :key="role" :value="role">
{{ role }}
</option>
</select>
</label>
<p>Selected: {{ selectedRole }}</p>
</template>
Checkbox (Single Boolean)
<script setup lang="ts">
const agreedToTerms = ref(false)
</script>
<template>
<label>
<input v-model="agreedToTerms" type="checkbox" />
I agree to the terms of service
</label>
</template>
Checkbox (Multiple Values)
When multiple checkboxes share the same v-model bound to an array, checked values are collected:
<script setup lang="ts">
const selectedFeatures = ref<string[]>([])
const features = ['Dark Mode', 'Notifications', 'Analytics', 'API Access']
</script>
<template>
<fieldset>
<legend>Features</legend>
<label v-for="feature in features" :key="feature">
<input v-model="selectedFeatures" type="checkbox" :value="feature" />
{{ feature }}
</label>
</fieldset>
<p>Selected: {{ selectedFeatures.join(', ') }}</p>
</template>
Radio Buttons
<script setup lang="ts">
const priority = ref('medium')
</script>
<template>
<fieldset>
<legend>Priority</legend>
<label>
<input v-model="priority" type="radio" value="low" /> Low
</label>
<label>
<input v-model="priority" type="radio" value="medium" /> Medium
</label>
<label>
<input v-model="priority" type="radio" value="high" /> High
</label>
</fieldset>
</template>
v-model Modifiers
.lazy
By default, v-model syncs on every input event (every keystroke). .lazy syncs on the change event instead (when the input loses focus):
<input v-model.lazy="searchQuery" type="text" />
<!-- Updates only when the user tabs away or presses Enter -->
.number
Casts the input value to a number. Without it, all input values are strings:
<script setup lang="ts">
const quantity = ref(1)
</script>
<template>
<input v-model.number="quantity" type="number" min="1" />
<!-- quantity.value is a number, not a string -->
</template>
.trim
Strips leading and trailing whitespace:
<input v-model.trim="username" type="text" />
<!-- " alice " becomes "alice" -->
Modifiers can be combined:
<input v-model.lazy.trim="comment" type="text" />
Form Submission
Use @submit.prevent to handle form submission without a page reload:
<script setup lang="ts">
const form = reactive({
name: '',
email: '',
message: ''
})
const isSubmitting = ref(false)
const submitted = ref(false)
async function handleSubmit() {
isSubmitting.value = true
try {
await $fetch('/api/contact', {
method: 'POST',
body: form
})
submitted.value = true
} catch (err) {
alert('Submission failed. Please try again.')
} finally {
isSubmitting.value = false
}
}
</script>
<template>
<div v-if="submitted">
<p>Thank you for your message. We will get back to you soon.</p>
</div>
<form v-else @submit.prevent="handleSubmit">
<label>
Name
<input v-model="form.name" type="text" required />
</label>
<label>
Email
<input v-model="form.email" type="email" required />
</label>
<label>
Message
<textarea v-model="form.message" required></textarea>
</label>
<button type="submit" :disabled="isSubmitting">
{{ isSubmitting ? 'Sending...' : 'Send' }}
</button>
</form>
</template>
Collecting Form Data with a Reactive Object
For forms with many fields, use a single reactive object instead of individual refs:
<script setup lang="ts">
interface RegistrationForm {
firstName: string
lastName: string
email: string
password: string
role: string
notifications: boolean
}
const form = reactive<RegistrationForm>({
firstName: '',
lastName: '',
email: '',
password: '',
role: 'viewer',
notifications: true
})
function resetForm() {
Object.assign(form, {
firstName: '',
lastName: '',
email: '',
password: '',
role: 'viewer',
notifications: true
})
}
async function handleSubmit() {
await $fetch('/api/register', {
method: 'POST',
body: { ...form }
})
resetForm()
}
</script>
<template>
<form @submit.prevent="handleSubmit">
<input v-model="form.firstName" type="text" placeholder="First name" />
<input v-model="form.lastName" type="text" placeholder="Last name" />
<input v-model="form.email" type="email" placeholder="Email" />
<input v-model="form.password" type="password" placeholder="Password" />
<select v-model="form.role">
<option value="viewer">Viewer</option>
<option value="editor">Editor</option>
<option value="admin">Admin</option>
</select>
<label>
<input v-model="form.notifications" type="checkbox" />
Receive notifications
</label>
<button type="submit">Register</button>
</form>
</template>
The Pattern: Bind, Handle, Feedback
Every form in Vue follows the same three-step pattern:
1. BIND: Reactive state <-> form inputs via v-model
2. HANDLE: @submit.prevent triggers an async function
3. FEEDBACK: Show loading state, success message, or errors
A complete example with all three steps:
<script setup lang="ts">
const form = reactive({ title: '', body: '' })
const status = ref<'idle' | 'submitting' | 'success' | 'error'>('idle')
const errorMessage = ref('')
async function handleSubmit() {
status.value = 'submitting'
errorMessage.value = ''
try {
await $fetch('/api/posts', {
method: 'POST',
body: { ...form }
})
status.value = 'success'
form.title = ''
form.body = ''
} catch (err: any) {
status.value = 'error'
errorMessage.value = err.data?.message || 'Something went wrong'
}
}
</script>
<template>
<form @submit.prevent="handleSubmit">
<div v-if="status === 'success'" class="success">
Post created successfully.
</div>
<div v-if="status === 'error'" class="error">
{{ errorMessage }}
</div>
<input v-model="form.title" type="text" placeholder="Title" required />
<textarea v-model="form.body" placeholder="Write your post..." required></textarea>
<button type="submit" :disabled="status === 'submitting'">
{{ status === 'submitting' ? 'Publishing...' : 'Publish' }}
</button>
</form>
</template>
Common Pitfalls
- Using
v-modelon a prop. Props are read-only.v-modelon a prop passed from a parent throws a warning. Emit an update event instead, or usedefineModel()in Vue 3.4+. - Forgetting
.preventon@submit. Without it, the browser performs a full page navigation on form submission, clearing all Vue state. - Not handling the loading state. Without disabling the submit button during submission, users can double-submit by clicking twice.
- Resetting reactive objects incorrectly. Assigning
form = { ... }to areactivevariable breaks reactivity. UseObject.assign(form, defaults)to reset fields. - Interpolating inside
<textarea>.<textarea>{{ text }}</textarea>does not work withv-model. Use<textarea v-model="text"></textarea>only. - Checkbox arrays without
:value. When usingv-modelwith an array for multiple checkboxes, each checkbox must have a:valueattribute. Without it, the array collectstrue/falseinstead of the intended values.
Key Takeaways
v-modelcreates two-way binding between form inputs and reactive state. It works with text, textarea, select, checkbox, and radio inputs.- Modifiers
.lazy,.number, and.trimadjust when and howv-modelsyncs values. @submit.preventhandles form submission without a page reload. Always disable the submit button during async operations.- Use a single
reactiveobject for forms with many fields. Reset withObject.assign, not reassignment. - Every form follows the bind-handle-feedback pattern: connect state to inputs, process submission, show the result.