3 min read
On this page

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-model on a prop. Props are read-only. v-model on a prop passed from a parent throws a warning. Emit an update event instead, or use defineModel() in Vue 3.4+.
  • Forgetting .prevent on @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 a reactive variable breaks reactivity. Use Object.assign(form, defaults) to reset fields.
  • Interpolating inside <textarea>. <textarea>{{ text }}</textarea> does not work with v-model. Use <textarea v-model="text"></textarea> only.
  • Checkbox arrays without :value. When using v-model with an array for multiple checkboxes, each checkbox must have a :value attribute. Without it, the array collects true/false instead of the intended values.

Key Takeaways

  • v-model creates two-way binding between form inputs and reactive state. It works with text, textarea, select, checkbox, and radio inputs.
  • Modifiers .lazy, .number, and .trim adjust when and how v-model syncs values.
  • @submit.prevent handles form submission without a page reload. Always disable the submit button during async operations.
  • Use a single reactive object for forms with many fields. Reset with Object.assign, not reassignment.
  • Every form follows the bind-handle-feedback pattern: connect state to inputs, process submission, show the result.