v-model creates a two-way binding between a form input and reactive data. It's shorthand for :value="data" + @input="data = $event.target.value". Vue's v-model is much simpler than React's controlled components.
<template>
<form @submit.prevent="handleSubmit">
<!-- Text input -->
<input v-model="form.name" placeholder="Name" />
<!-- Email -->
<input v-model="form.email" type="email" placeholder="Email" />
<!-- Number with .number modifier -->
<input v-model.number="form.age" type="number" />
<!-- Textarea -->
<textarea v-model="form.bio" rows="4" />
<!-- Select -->
<select v-model="form.country">
<option value="">Select country</option>
<option value="us">United States</option>
<option value="uk">United Kingdom</option>
<option value="in">India</option>
</select>
<!-- Multi-select -->
<select v-model="form.skills" multiple>
<option v-for="skill in availableSkills" :key="skill" :value="skill">
{{ skill }}
</option>
</select>
<!-- Radio buttons -->
<label v-for="role in roles" :key="role">
<input type="radio" v-model="form.role" :value="role" />
{{ role }}
</label>
<!-- Checkbox (boolean) -->
<label>
<input type="checkbox" v-model="form.newsletter" />
Subscribe to newsletter
</label>
<!-- Checkbox (array) -->
<label v-for="tag in availableTags" :key="tag">
<input type="checkbox" v-model="form.tags" :value="tag" />
{{ tag }}
</label>
<button type="submit">Submit</button>
<pre>{{ JSON.stringify(form, null, 2) }}</pre>
</form>
</template>
<script setup>
import { reactive } from 'vue'
const form = reactive({
name: '', email: '', age: 0, bio: '',
country: '', skills: [], role: 'user',
newsletter: false, tags: []
})
const availableSkills = ['Vue', 'React', 'Angular', 'Node.js']
const roles = ['user', 'admin', 'moderator']
const availableTags = ['Frontend', 'Backend', 'DevOps', 'Design']
function handleSubmit() {
console.log('Form submitted:', form)
}
</script>
<template>
<form @submit.prevent="handleSubmit">
<div class="field">
<input v-model="form.email" type="email" placeholder="Email"
:class="{ 'input-error': errors.email }" @blur="validateField('email')" />
<span v-if="errors.email" class="error">{{ errors.email }}</span>
</div>
<div class="field">
<input v-model="form.password" type="password" placeholder="Password"
:class="{ 'input-error': errors.password }" @blur="validateField('password')" />
<span v-if="errors.password" class="error">{{ errors.password }}</span>
</div>
<button type="submit" :disabled="!isValid">Login</button>
</form>
</template>
<script setup>
import { reactive, computed } from 'vue'
const form = reactive({ email: '', password: '' })
const errors = reactive({ email: '', password: '' })
function validateField(field) {
if (field === 'email') {
if (!form.email) errors.email = 'Email is required'
else if (!/\S+@\S+\.\S+/.test(form.email)) errors.email = 'Invalid email'
else errors.email = ''
}
if (field === 'password') {
if (!form.password) errors.password = 'Password is required'
else if (form.password.length < 8) errors.password = 'Min 8 characters'
else errors.password = ''
}
}
const isValid = computed(() =>
form.email && form.password && !errors.email && !errors.password
)
function handleSubmit() {
validateField('email')
validateField('password')
if (isValid.value) alert('Login successful!')
}
</script>
Understanding Forms is not just about syntax. In production applications, this topic directly affects maintainability, debugging speed, and team collaboration. Focus on readability, small reusable patterns, and predictable state flow when implementing Forms.
A practical approach is to first implement the simplest working version, then refactor into reusable pieces (components/composables/stores) only when duplication appears. This helps keep your Vue codebase clean while avoiding over-engineering.
Explore 500+ free tutorials across 20+ languages and frameworks.