Validasi Form di Vue Tanpa Library Berat
Setiap kali mulai proyek Vue baru, ada godaan untuk langsung install library validasi form begitu ada lebih dari dua input yang perlu dicek. Saya sendiri dulu begitu — hampir semua proyek Laravel + Vue yang saya pegang, entah untuk sistem internal fasilitas kesehatan atau dashboard admin biasa, ujung-ujungnya pakai VeeValidate atau sejenisnya walaupun formnya, misalnya, cuma lima field. Masalahnya, library validasi itu punya learning curve sendiri: schema, resolver, provider, composable khusus. Untuk form yang tidak kompleks-kompleks amat, itu overhead yang sebenarnya tidak perlu. Vue sendiri, lewat computed dan watch, sudah menyediakan primitif yang cukup untuk validasi reaktif tanpa dependency tambahan.
Pola dasar: computed sebagai sumber kebenaran error
Ide intinya sederhana: aturan validasi ditulis sebagai fungsi murni, lalu hasilnya dibungkus computed supaya otomatis re-evaluasi setiap kali data form berubah. Tidak perlu trigger manual, tidak perlu event handler khusus di setiap input.
const form = reactive({
email: '',
password: '',
})
const errors = computed(() => {
const e = {}
if (!form.email) {
e.email = 'Email wajib diisi'
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email)) {
e.email = 'Format email tidak valid'
}
if (form.password.length < 8) {
e.password = 'Password minimal 8 karakter'
}
return e
})
const isValid = computed(() => Object.keys(errors.value).length === 0)
Dengan pola ini, template tinggal membaca errors.email atau errors.password untuk menampilkan pesan, dan tombol submit tinggal di-disable berdasarkan isValid. Tidak ada state error yang harus di-sync manual — semuanya turunan (derived state) dari data form itu sendiri, sesuai prinsip reaktivitas Vue yang memang dirancang untuk kasus seperti ini.
Menghindari validasi yang terlalu agresif dengan watch
Masalah umum dari pendekatan computed murni adalah: error langsung muncul begitu form di-render, padahal user belum sempat mengetik apa pun. Ini pengalaman yang mengganggu — field kosong langsung merah sebelum user menyentuhnya. Solusinya adalah menambahkan state "sudah disentuh" (touched) per field, dan di sinilah watch berguna, atau cukup event @blur di template.
const touched = reactive({ email: false, password: false })
function markTouched(field) {
touched[field] = true
}
// di template: @blur="markTouched('email')"
// tampilkan error hanya jika touched.email dan errors.value.email
Alternatifnya, kalau validasi butuh logika yang lebih berat — misalnya cek ketersediaan email ke server, yang tidak bisa dilakukan secara sinkron di computed — di situlah watch dengan debounce jadi pilihan yang lebih tepat dibanding computed:
let debounceTimer = null
watch(() => form.email, (newEmail) => {
clearTimeout(debounceTimer)
debounceTimer = setTimeout(async () => {
if (newEmail) {
const res = await checkEmailAvailability(newEmail)
serverErrors.email = res.available ? null : 'Email sudah terdaftar'
}
}, 400)
})
Prinsip pembagian tugasnya jadi jelas: computed untuk validasi sinkron yang murni fungsi dari state saat ini, watch untuk validasi yang punya efek samping seperti panggilan API atau logika yang perlu di-debounce.
Menyatukan pola ini jadi composable kecil
Kalau pola di atas dipakai berulang di beberapa form, wajar untuk menariknya jadi composable sendiri — bukan library, hanya fungsi biasa yang dipanggil useFormValidation misalnya. Ini tetap dalam semangat "tanpa library berat": kodenya milik proyek sendiri, mudah dibaca oleh siapa pun di tim, dan tidak menambah bundle size dari dependency luar.
function useFormValidation(form, rules) {
const errors = computed(() => {
const e = {}
for (const field in rules) {
const message = rules[field](form[field], form)
if (message) e[field] = message
}
return e
})
const isValid = computed(() => Object.keys(errors.value).length === 0)
return { errors, isValid }
}
Fungsi rules di sini cukup berupa objek berisi fungsi validator per field, sehingga aturan bisa dipakai ulang antar form tanpa harus menulis ulang logika computed-nya setiap kali.
Kapan sebaiknya tetap pakai library
Pola manual ini punya batas kewajaran. Kalau form punya puluhan field dengan validasi bersyarat yang saling bergantung (misalnya field A wajib hanya jika field B bernilai tertentu, dikombinasikan dengan array field dinamis), atau kalau tim butuh schema validasi yang juga dipakai ulang di backend, library seperti VeeValidate atau validasi berbasis schema (Zod, Yup) mulai masuk akal karena menyediakan abstraksi untuk kasus-kasus itu. Intinya bukan "library selalu buruk", tapi jangan pasang dependency besar untuk masalah yang sebenarnya bisa diselesaikan dengan dua primitif reaktif yang sudah ada di Vue.
Takeaway singkat: untuk form yang tidak terlalu kompleks, computed sudah cukup sebagai sumber kebenaran untuk error validasi sinkron, dan watch dipakai khusus untuk validasi asinkron atau yang butuh debounce. Tambahkan state "touched" supaya UX tidak terasa agresif. Baru pertimbangkan library eksternal kalau kompleksitas form sudah benar-benar melewati apa yang wajar ditangani dengan pola reaktif sederhana ini.