This episode covers form management in Vue: two-way binding with v-model across different input types, validation patterns with VeeValidate and Vuelidate, dynamic forms with custom validation rules, plus form accessibility and user feedback.

Forms are the bridge between an application and its users: the place where you collect data, validate input, and show errors when data doesn't fit. A bad form frustrates users and lets dirty data into the backend.
Episode 11 covers form management in Vue thoroughly: input binding with v-model, validation patterns with VeeValidate and its alternative Vuelidate, dynamic forms with custom validation rules, plus accessibility and feedback so forms are comfortable for everyone.
v-model works with almost every form element: text, checkbox, radio, and select. Vue handles the differing values and events behind the scenes:
<script setup>
import { ref } from "vue";
const nama = ref("");
const aktif = ref(false);
const paket = ref("pro");
</script>
<template>
<input v-model="nama" />
<input type="checkbox" v-model="aktif" />
<select v-model="paket">
<option value="gratis">Gratis</option>
<option value="pro">Pro</option>
</select>
</template>v-model="aktif" on a checkbox produces a boolean, while v-model="paket" on a select binds the value of the chosen option. One directive, many input types handled automatically.
For large forms, collect state in a single reactive object and handle submission with @submit.prevent so the page doesn't reload.
VeeValidate is the most popular validation library in the Vue ecosystem. Install it together with the Yup schema validator:
npm install vee-validate yupVeeValidate uses the useField and useForm composables, which integrate with the Composition API:
<script setup>
import { useField, useForm } from "vee-validate";
import * as yup from "yup";
const { handleSubmit } = useForm({
validationSchema: yup.object({
email: yup.string().email("Email tidak valid").required("Email wajib"),
password: yup.string().min(6, "Minimal 6 karakter").required(),
}),
});
const { value: email, errorMessage: emailError } = useField("email");
const { value: password, errorMessage: passError } = useField("password");
const onKirim = handleSubmit((nilai) => {
console.log("valid:", nilai);
});
</script>
<template>
<form @submit="onKirim">
<input v-model="email" />
<span>{{ emailError }}</span>
<input v-model="password" type="password" />
<span>{{ passError }}</span>
<button>Kirim</button>
</form>
</template>validationSchema is defined once with Yup, then useField("email") connects the field to that schema. errorMessage holds the ready-to-display error message.
Built-in rules aren't always enough. Create your own rule with a validator function:
import * as yup from "yup";
const schema = yup.object({
username: yup
.string()
.test("unik", "Username sudah dipakai", async (nilai) => {
const res = await fetch(`/api/cek-username?nama=${nilai}`);
const { tersedia } = await res.json();
return tersedia;
}),
});yup.string().test(...) accepts a rule name, an error message, and a validation function. Because it can be async, server-side checks like username lookups run smoothly.
Some forms let users add rows, for example an order items list:
<script setup>
import { useFieldArray } from "vee-validate";
const { fields, push, remove } = useFieldArray("items");
</script>
<template>
<div v-for="(field, index) in fields" :key="field.key">
<input v-model="field.value.nama" />
<input v-model="field.value.qty" />
<button @click="remove(index)">Hapus</button>
</div>
<button @click="push({ nama: "", qty: 1 })">Tambah baris</button>
</template>useFieldArray("items") manages an array of fields with push to add and remove to delete. Each row gets a unique key so Vue renders correctly.
Vuelidate attaches rules directly to reactive state and can be installed with npm install @vuelidate/core @vuelidate/validators. Errors are accessed through $errors, and $error flags a field that failed validation.
An accessible form tells screen readers about errors and guides focus:
<template>
<label for="email">Email</label>
<input
id="email"
type="email"
aria-describedby="email-hint"
:aria-invalid="!!emailError"
/>
<p id="email-hint">{{ emailError }}</p>
</template>aria-describedby="email-hint" connects the input to the helper message, and :aria-invalid="!!emailError" flags the field that failed validation.
Rules of thumb for form feedback: show errors near the field in question, use human-readable text, don't rely on red color alone as the only indicator, and validate when a field loses focus rather than on every keystroke.
Episode 11 made you manage forms with confidence: v-model for every input type, VeeValidate with Yup for schema-based validation, Vuelidate as a lightweight alternative, dynamic forms with useFieldArray, plus accessibility and feedback that guides users.
Key takeaways:
v-model handles all form input types..test() can check against the server.useFieldArray for dynamic forms.In the next episode 12, we'll cover authentication and authorization — client-side authentication patterns in Vue, token storage and refresh flows, route guards for protected pages, and role-based access control that restricts the UI according to a user's permissions.