Web Development

Vue.js untuk Pemula: Framework Progresif

Panduan lengkap belajar Vue.js dari nol — Vue instance, template syntax, directives, computed properties, components, props, events, dan Vue Router

1. Pengenalan Vue.js

Vue.js adalah framework JavaScript progresif untuk membangun antarmuka pengguna (UI) di browser. Dibuat oleh Evan You pada tahun 2014, Vue dirancang untuk bisa diadopsi secara bertahap — dari memperkaya halaman HTML sederhana hingga membangun Single Page Application (SPA) yang kompleks.

Vue.js menggunakan pendekatan reactive data binding yang secara otomatis memperbarui DOM ketika data berubah. Ini menghilangkan kebutuhan untuk memanipulasi DOM secara manual — Anda cukup mengubah data, dan Vue akan memperbarui tampilan.

Mengapa Memilih Vue.js?

Keunggulan Penjelasan
ProgressiveBisa diadopsi bertahap — dari script tag biasa hingga SPA kompleks
ReactiveData binding otomatis — ubah data, tampilan berubah sendiri
Komponen-BasedUI dibangun dari komponen kecil yang bisa digunakan ulang
SFC (Single File Component)HTML, CSS, dan JS dalam satu file .vue yang rapi
Composition APIAPI modern untuk logika yang lebih terorganisir di komponen besar
RinganUkuran kecil (~33KB min+gzip) — loading cepat
Ekosistem KayaVue Router, Pinia (state management), Vite (build tool), Nuxt (SSR)

Vue.js vs Framework Lain

Aspek Vue.js React Angular
Dikembangkan OlehKomunitas (Evan You)Meta (Facebook)Google
Ukuran~33KB~42KB~143KB
Learning Curve🟢 Mudah🟡 Sedang🔴 Curam
TemplateHTML templateJSXHTML template + directives
State ManagementPinia (official)Redux / ZustandNgRx / Services
Build ToolVite (official)Vite / CRAAngular CLI
Cocok untukSemua skala, cepat mulaiEkosistem besar, fleksibelEnterprise, struktur ketat

Instalasi Vue.js

Bash — Instalasi Vue
# ===== Cara 1: CDN (untuk eksperimen cepat) =====
# <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>


# ===== Cara 2: Vite + Vue (REKOMENDASI) =====
npm create vue@latest

# Ikuti wizard:
# ✔ Project name: my-vue-app
# ✔ Add TypeScript? No / Yes
# ✔ Add Vue Router? Yes
# ✔ Add Pinia? Yes
# ✔ Add Vitest? No

cd my-vue-app
npm install
npm run dev    # Jalankan development server

# Output:
# VITE v5.x.x  ready in 300 ms
# ➜ Local:   http://localhost:5173/


# ===== Cara 3: Vue CLI (alternatif) =====
npm install -g @vue/cli
vue create my-project
cd my-project
npm run serve
Diagram: Ekosistem Vue.js
┌─────────────────────────────────────────────────────────────────┐
│                     EKOSISTEM VUE.JS                             │
│                                                                 │
│  ┌──────────────┐   ┌──────────────┐   ┌────────────────────┐  │
│  │   Vue Router  │   │    Pinia     │   │   Vite             │  │
│  │  (Routing)    │   │ (State Mgmt) │   │  (Build Tool)      │  │
│  └──────┬───────┘   └──────┬───────┘   └────────┬───────────┘  │
│         │                  │                     │              │
│         └──────────────────┼─────────────────────┘              │
│                            ▼                                    │
│                   ┌──────────────────┐                          │
│                   │    Vue.js Core   │  ← Reactivity System     │
│                   │   (Vue 3.x)      │     Template Compiler    │
│                   └────────┬─────────┘     Virtual DOM          │
│                            ▼                                    │
│                   ┌──────────────────┐                          │
│                   │  Single File     │  ← .vue files            │
│                   │  Components (SFC)│     <template> +         │
│                   └──────────────────┘     <script setup> +     │
│                                            <style scoped>       │
└─────────────────────────────────────────────────────────────────┘

2. Vue Instance & Composition API

Vue 3 memperkenalkan Composition API sebagai alternatif dari Options API yang lebih lama. Composition API menggunakan fungsi setup() atau <script setup> untuk mendefinisikan logika komponen.

Vue Instance Pertama (CDN)

HTML — Vue Instance Pertama
<!DOCTYPE html>
<html lang="id">
<head>
    <meta charset="UTF-8">
    <title>Vue.js Pertama</title>
</head>
<body>
    <div id="app">
        <h1>{{ pesan }}</h1>
        <p>Nama: {{ nama }}</p>
        <button @click="ubahNama">Ubah Nama</button>
    </div>

    <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
    <script>
        const { createApp, ref } = Vue;

        createApp({
            setup() {
                const pesan = ref('Halo, Vue.js!');
                const nama = ref('Budi');

                function ubahNama() {
                    nama.value = nama.value === 'Budi' ? 'Ani' : 'Budi';
                }

                return { pesan, nama, ubahNama };
            }
        }).mount('#app');
    </script>
</body>
</html>

Composition API dengan <script setup>

Vue — Komponen dengan script setup
<template>
  <div class="counter">
    <h2>Penghitung: {{ count }}</h2>
    <p>Genap/Ganjil: {{ isEven ? 'Genap' : 'Ganjil' }}</p>
    <button @click="increment">Tambah (+)</button>
    <button @click="decrement">Kurang (-)</button>
    <button @click="reset">Reset</button>
  </div>
</template>

<script setup>
import { ref, computed } from 'vue';

// Reactive state dengan ref()
const count = ref(0);

// Computed property
const isEven = computed(() => count.value % 2 === 0);

// Methods
function increment() {
    count.value++;
}

function decrement() {
    count.value--;
}

function reset() {
    count.value = 0;
}
</script>

<style scoped>
.counter {
    text-align: center;
    padding: 20px;
}
button {
    margin: 0 5px;
    padding: 8px 16px;
    cursor: pointer;
}
</style>

Reactivity: ref() vs reactive()

Vue — ref vs reactive
<script setup>
import { ref, reactive, toRefs } from 'vue';

// ===== ref() — Untuk nilai tunggal =====
// Menggunakan .value untuk mengakses/mengubah nilai
const nama = ref('Budi');         // nama.value = 'Budi'
const umur = ref(25);             // umur.value = 25
const isActive = ref(true);       // isActive.value = true

// Di template, .value TIDAK perlu ditulis
// <template>{{ nama }}</template> (bukan {{ nama.value }})

// ===== reactive() — Untuk object =====
// Tidak perlu .value, langsung akses property
const user = reactive({
    name: 'Budi Santoso',
    age: 25,
    address: {
        city: 'Jakarta',
        province: 'DKI Jakarta'
    }
});

// Mengubah reactive object langsung
user.name = 'Ani Wijaya';
user.address.city = 'Bandung';

// JANGAN destructure reactive — hilang reaktivitas!
// const { name, age } = user; // ❌ name dan age TIDAK reaktif

// Gunakan toRefs jika perlu destructure
const { name, age } = toRefs(user); // ✅ name.value dan age.value reaktif
</script>
💡 Kapan Menggunakan ref vs reactive?

Gunakan ref() untuk nilai tunggal (string, number, boolean) dan saat Anda ingin me-replace seluruh nilai. Gunakan reactive() untuk object dan array yang kompleks. Secara umum, ref lebih fleksibel dan bisa digunakan di mana saja, termasuk sebagai argumen fungsi.

3. Template Syntax

Vue menggunakan sintaks berbasis HTML untuk mengikat data ke DOM. Ini termasuk text interpolation, expressions, dan HTML binding.

Vue — Template Syntax
<template>
  <div>

    <!-- ===== TEXT INTERPOLATION ===== -->
    <h1>{{ judul }}</h1>
    <p>Halo, {{ nama.toUpperCase() }}!</p>
    <p>{{ jumlah > 10 ? 'Banyak' : 'Sedikit' }}</p>

    <!-- ===== EXPRESSIONS ===== -->
    <p>Total: {{ harga * jumlah }}</p>
    <p>Nama terbalik: {{ nama.split('').reverse().join('') }}</p>

    <!-- ===== RAW HTML (v-html) ===== -->
    <!-- HATI-HATI: bisa XSS jika input dari user! -->
    <div v-html="kontenHtml"></div>

    <!-- ===== ATTRIBUTE BINDING (v-bind / :) ===== -->
    <img :src="gambarUrl" :alt="gambarAlt">
    <a :href="linkTujuan" :class="{ aktif: isActive }">Klik</a>
    <button :disabled="isLoading">
      {{ isLoading ? 'Loading...' : 'Simpan' }}
    </button>

    <!-- ===== DYNAMIC ATTRIBUTE ===== -->
    <div :[attrName]="nilai">Dynamic attr</div>
    <!-- attrName = "id" → <div id="nilai"> -->

    <!-- ===== CLASS BINDING ===== -->
    <div :class="{ aktif: isActive, error: hasError }">
      Object syntax
    </div>
    <div :class="[isActive ? 'aktif' : '', 'base-class']">
      Array syntax
    </div>

    <!-- ===== STYLE BINDING ===== -->
    <div :style="{ color: warnaText, fontSize: ukuran + 'px' }">
      Inline style
    </div>

  </div>
</template>

<script setup>
import { ref } from 'vue';

const judul = ref('Belajar Vue.js');
const nama = ref('BeebaneLabs');
const jumlah = ref(5);
const harga = ref(150000);
const kontenHtml = ref('<strong>Teks tebal</strong>');
const gambarUrl = ref('/images/vue-logo.png');
const gambarAlt = ref('Logo Vue.js');
const linkTujuan = ref('https://vuejs.org');
const isActive = ref(true);
const hasError = ref(false);
const isLoading = ref(false);
const attrName = ref('id');
const nilai = ref('app-container');
const warnaText = ref('#42b883');
const ukuran = ref(18);
</script>

4. Directives

Directives adalah atribut khusus yang diawali v-. Directives memberikan fungsionalitas reaktif ke DOM — conditional rendering, list rendering, event handling, dan two-way binding.

v-if, v-else-if, v-else (Conditional Rendering)

Vue — Conditional Rendering
<template>
  <div>
    <!-- v-if: elemen benar-benar ditambah/dihapus dari DOM -->
    <div v-if="isLoggedIn">
      <p>Selamat datang, {{ user.name }}!</p>
      <button @click="logout">Logout</button>
    </div>

    <!-- v-else-if dan v-else -->
    <div v-else-if="isLoading">
      <p>⏳ Memuat data...</p>
    </div>

    <div v-else>
      <p>Silakan login untuk melanjutkan.</p>
      <button @click="login">Login</button>
    </div>

    <!-- v-show: elemen selalu ada di DOM, hanya display:none -->
    <div v-show="showNotifikasi">
      <p>🔔 Ada pesan baru!</p>
    </div>

    <!-- Perbedaan: v-if vs v-show -->
    <!-- v-if → cocok untuk kondisi jarang berubah (hemat DOM) -->
    <!-- v-show → cocok untuk toggle cepat (hemat render ulang) -->
  </div>
</template>

<script setup>
import { ref } from 'vue';

const isLoggedIn = ref(false);
const isLoading = ref(false);
const showNotifikasi = ref(true);
const user = ref({ name: 'Budi' });

function login() { isLoggedIn.value = true; }
function logout() { isLoggedIn.value = false; }
</script>

v-for (List Rendering)

Vue — List Rendering
<template>
  <div>
    <!-- v-for dengan array -->
    <ul>
      <li v-for="(buah, index) in buahList" :key="buah.id">
        {{ index + 1 }}. {{ buah.nama }} - Rp {{ buah.harga.toLocaleString() }}
      </li>
    </ul>

    <!-- v-for dengan object -->
    <div v-for="(nilai, kunci, index) in profil" :key="kunci">
      <strong>{{ index }}. {{ kunci }}:</strong> {{ nilai }}
    </div>

    <!-- v-for dengan range -->
    <span v-for="n in 5" :key="n">{{ n }} </span>
    <!-- Output: 1 2 3 4 5 -->

    <!-- v-for + v-if (HATI-HATI!) -->
    <!-- Vue 3: v-if punya prioritas lebih tinggi dari v-for -->
    <li v-for="item in items" :key="item.id" v-if="item.aktif">
      {{ item.nama }}
    </li>
    <!-- Lebih baik gunakan computed property untuk filter -->
    <li v-for="item in activeItems" :key="item.id">
      {{ item.nama }}
    </li>
  </div>
</template>

<script setup>
import { ref, computed } from 'vue';

const buahList = ref([
    { id: 1, nama: 'Apel', harga: 15000 },
    { id: 2, nama: 'Mangga', harga: 20000 },
    { id: 3, nama: 'Jeruk', harga: 12000 },
    { id: 4, nama: 'Pisang', harga: 8000 }
]);

const profil = ref({
    nama: 'Budi Santoso',
    email: 'budi@beebane.com',
    kota: 'Jakarta'
});

const items = ref([
    { id: 1, nama: 'Item A', aktif: true },
    { id: 2, nama: 'Item B', aktif: false },
    { id: 3, nama: 'Item C', aktif: true }
]);

// Filter dengan computed property (lebih baik dari v-if di dalam v-for)
const activeItems = computed(() =>
    items.value.filter(item => item.aktif)
);
</script>

v-on (@) & v-model

Vue — Events & Two-way Binding
<template>
  <div>
    <!-- v-on / @ untuk event handling -->
    <button @click="handleClick">Klik Saya</button>
    <button @click="handleClick($event)">Dengan Event Object</button>
    <button @click.prevent="handleSubmit">Prevent Default</button>
    <button @click.once="hanyaSekali">Hanya Sekali</button>

    <!-- Event modifiers -->
    <!-- .stop    → event.stopPropagation() -->
    <!-- .prevent → event.preventDefault() -->
    <!-- .once    → handler hanya jalan sekali -->
    <!-- .capture → gunakan capture phase -->
    <!-- .self    → hanya trigger jika event dari elemen ini -->

    <!-- Key modifiers -->
    <input @keyup.enter="submitForm">
    <input @keyup.esc="cancel">
    <input @keyup.ctrl.s="save">

    <!-- v-model: Two-way binding -->
    <input v-model="nama" placeholder="Masukkan nama">
    <p>Nama: {{ nama }}</p>

    <!-- v-model dengan textarea -->
    <textarea v-model="bio" placeholder="Bio"></textarea>

    <!-- v-model dengan checkbox -->
    <input type="checkbox" v-model="setuju" id="agree">
    <label for="agree">Saya setuju</label>

    <!-- v-model dengan select -->
    <select v-model="kota">
      <option disabled value="">Pilih kota</option>
      <option>Jakarta</option>
      <option>Bandung</option>
      <option>Surabaya</option>
    </select>

    <!-- v-model dengan radio -->
    <input type="radio" v-model="gender" value="L"> Laki-laki
    <input type="radio" v-model="gender" value="P"> Perempuan

    <!-- v-model modifiers -->
    <input v-model.trim="search">    <!-- Trim otomatis -->
    <input v-model.number="umur">    <!-- Konversi ke number -->
    <input v-model.lazy="query">     <!-- Update saat blur, bukan saat ketik -->
  </div>
</template>

<script setup>
import { ref } from 'vue';

const nama = ref('');
const bio = ref('');
const setuju = ref(false);
const kota = ref('');
const gender = ref('');
const search = ref('');
const umur = ref(0);
const query = ref('');

function handleClick(event) {
    console.log('Diklik!', event.target);
}
function handleSubmit() { console.log('Submit!'); }
function hanyaSekali() { console.log('Hanya jalan sekali!'); }
function submitForm() { console.log('Enter ditekan!'); }
function cancel() { console.log('Escape ditekan!'); }
function save() { console.log('Ctrl+S ditekan!'); }
</script>

5. Computed Properties & Watchers

Computed properties adalah nilai yang dihitung otomatis dari data reaktif. Watchers memungkinkan Anda menjalankan side effect ketika data berubah.

Vue — Computed Properties
<template>
  <div>
    <h2>Belanja Online</h2>
    <input v-model="searchQuery" placeholder="Cari barang...">

    <div v-for="item in filteredItems" :key="item.id">
      <span>{{ item.name }} - Rp {{ item.price.toLocaleString() }}</span>
      <button @click="addToCart(item)">+ Keranjang</button>
    </div>

    <hr>
    <h3>Keranjang ({{ cartCount }} item)</h3>
    <ul>
      <li v-for="item in cart" :key="item.id">
        {{ item.name }} x{{ item.qty }}
      </li>
    </ul>
    <p><strong>Total: Rp {{ cartTotal.toLocaleString() }}</strong></p>
    <p>Diskon: {{ discountText }}</p>
  </div>
</template>

<script setup>
import { ref, computed } from 'vue';

const searchQuery = ref('');
const items = ref([
    { id: 1, name: 'Laptop ASUS', price: 12000000 },
    { id: 2, name: 'Keyboard Mekanik', price: 850000 },
    { id: 3, name: 'Mouse Logitech', price: 350000 },
    { id: 4, name: 'Monitor 27"', price: 4500000 }
]);
const cart = ref([]);

// Computed: Filter items berdasarkan search
const filteredItems = computed(() => {
    const query = searchQuery.value.toLowerCase();
    return items.value.filter(item =>
        item.name.toLowerCase().includes(query)
    );
});

// Computed: Hitung jumlah item di keranjang
const cartCount = computed(() =>
    cart.value.reduce((sum, item) => sum + item.qty, 0)
);

// Computed: Hitung total harga
const cartTotal = computed(() =>
    cart.value.reduce((sum, item) => sum + item.price * item.qty, 0)
);

// Computed: Teks diskon
const discountText = computed(() => {
    const total = cartTotal.value;
    if (total >= 10000000) return 'Diskon 15%! 🎉';
    if (total >= 5000000) return 'Diskon 10%!';
    if (total >= 1000000) return 'Diskon 5%';
    return 'Belum ada diskon';
});

function addToCart(item) {
    const existing = cart.value.find(c => c.id === item.id);
    if (existing) {
        existing.qty++;
    } else {
        cart.value.push({ ...item, qty: 1 });
    }
}
</script>

Watchers

Vue — Watchers
<script setup>
import { ref, watch, watchEffect } from 'vue';

const query = ref('');
const results = ref([]);
const isLoading = ref(false);
const page = ref(1);

// ===== watch() — Mengawasi satu ref =====
watch(query, (newVal, oldVal) => {
    console.log(`Query berubah: "${oldVal}" → "${newVal}"`);
    // Reset halaman saat query berubah
    page.value = 1;
    // Panggil API jika query tidak kosong
    if (newVal.length >= 3) {
        searchAPI(newVal);
    }
});

// ===== watch() dengan immediate =====
watch(page, (newPage) => {
    console.log(`Halaman: ${newPage}`);
    searchAPI(query.value);
}, { immediate: false }); // immediate: true → jalankan saat mount

// ===== watch() dengan deep =====
const user = ref({ name: 'Budi', settings: { theme: 'dark' } });
watch(user, (newVal) => {
    console.log('User berubah:', JSON.stringify(newVal));
}, { deep: true }); // Deep watch — mendeteksi perubahan nested

// ===== watchEffect() — Auto-track dependencies =====
watchEffect(() => {
    // Otomatis mengawasi semua ref yang digunakan di dalam
    console.log(`Mencari: ${query.value}, Halaman: ${page.value}`);
    // Tidak perlu menyebutkan dependency secara eksplisit
});

// ===== Contoh: Debounced API search =====
let timeout;
watch(query, (newVal) => {
    clearTimeout(timeout);
    timeout = setTimeout(() => {
        if (newVal.length >= 3) {
            searchAPI(newVal);
        }
    }, 300); // Tunggu 300ms setelah user berhenti mengetik
});

async function searchAPI(q) {
    isLoading.value = true;
    // Simulasi API call
    await new Promise(r => setTimeout(r, 500));
    results.value = [`Hasil untuk "${q}" - 1`, `Hasil untuk "${q}" - 2`];
    isLoading.value = false;
}
</script>
📖 Computed vs Watch

Gunakan computed untuk menghitung nilai baru dari data reaktif (harus mengembalikan nilai). Gunakan watch untuk menjalankan side effect saat data berubah (API call, logging, localStorage). Jika logika Anda hanya menghitung nilai, gunakan computed — lebih efisien karena caching otomatis.

6. Komponen Vue

Single File Component (SFC) adalah fitur unggulan Vue — menggabungkan template, script, dan style dalam satu file .vue. Ini membuat komponen lebih mudah dipelihara dan diorganisir.

Anatomi SFC

Vue — Single File Component
<!-- File: src/components/UserCard.vue -->

<template>
  <!-- Template: Struktur HTML komponen (WAJIB ada) -->
  <div class="user-card">
    <img :src="avatar" :alt="name" class="avatar">
    <h3>{{ name }}</h3>
    <p class="email">{{ email }}</p>
    <span :class="['badge', role]">{{ role }}</span>
    <button @click="$emit('select', { id, name })">
      Pilih
    </button>
  </div>
</template>

<script setup>
// Script: Logika komponen (Composition API)
// Props dan emits didefinisikan dengan defineProps / defineEmits

const props = defineProps({
    id: { type: Number, required: true },
    name: { type: String, required: true },
    email: { type: String, default: '' },
    avatar: { type: String, default: '/images/default-avatar.png' },
    role: {
        type: String,
        default: 'user',
        validator: (value) => ['admin', 'editor', 'user'].includes(value)
    }
});

const emit = defineEmits(['select', 'delete']);
</script>

<style scoped>
/* Style: CSS khusus komponen */
/* scoped = hanya berlaku di komponen ini */
.user-card {
    border: 1px solid #ddd;
    border-radius: 12px;
    padding: 20px;
    text-align: center;
}
.avatar {
    width: 80px;
    height: 80px;
    border-radius: 50%;
}
.badge {
    padding: 2px 8px;
    border-radius: 4px;
    font-size: 12px;
}
.badge.admin { background: #ff6b6b; color: white; }
.badge.editor { background: #4ecdc4; color: white; }
.badge.user { background: #95afc0; color: white; }
</style>

Menggunakan Komponen

Vue — Menggunakan Komponen
<!-- File: src/App.vue -->

<template>
  <div id="app">
    <h1>Daftar Pengguna</h1>

    <!-- Menggunakan UserCard component -->
    <div class="user-grid">
      <UserCard
        v-for="user in users"
        :key="user.id"
        :id="user.id"
        :name="user.name"
        :email="user.email"
        :avatar="user.avatar"
        :role="user.role"
        @select="handleSelect"
      />
    </div>

    <!-- Komponen tanpa props -->
    <Footer />
  </div>
</template>

<script setup>
import UserCard from './components/UserCard.vue';
import Footer from './components/Footer.vue';
import { ref } from 'vue';

const users = ref([
    {
        id: 1,
        name: 'Budi Santoso',
        email: 'budi@beebane.com',
        avatar: '/images/budi.jpg',
        role: 'admin'
    },
    {
        id: 2,
        name: 'Ani Wijaya',
        email: 'ani@beebane.com',
        avatar: '/images/ani.jpg',
        role: 'editor'
    },
    {
        id: 3,
        name: 'Citra Dewi',
        email: 'citra@beebane.com',
        avatar: '',
        role: 'user'
    }
]);

function handleSelect(user) {
    console.log('User dipilih:', user);
}
</script>

7. Props & Events

Props adalah cara mengirim data dari parent ke child component (one-way flow). Events adalah cara child mengirim sinyal ke parent. Ini membentuk pola komunikasi yang terstruktur dan mudah di-debug.

Vue — Parent Component
<!-- Parent: TodoApp.vue -->
<template>
  <div class="todo-app">
    <h1>📝 Todo List</h1>

    <!-- Kirim data ke child melalui props -->
    <!-- Tangkap event dari child -->
    <TodoInput @add="addTodo" />

    <TodoList
      :todos="todos"
      :filter="currentFilter"
      @toggle="toggleTodo"
      @delete="deleteTodo"
    />

    <TodoFilter
      :current="currentFilter"
      :counts="filterCounts"
      @change="currentFilter = $event"
    />
  </div>
</template>

<script setup>
import { ref, computed } from 'vue';
import TodoInput from './TodoInput.vue';
import TodoList from './TodoList.vue';
import TodoFilter from './TodoFilter.vue';

const todos = ref([
    { id: 1, text: 'Belajar Vue.js', done: false },
    { id: 2, text: 'Buat proyek', done: false },
    { id: 3, text: 'Baca dokumentasi', done: true }
]);

const currentFilter = ref('all');
let nextId = 4;

const filterCounts = computed(() => ({
    all: todos.value.length,
    active: todos.value.filter(t => !t.done).length,
    done: todos.value.filter(t => t.done).length
}));

function addTodo(text) {
    todos.value.push({ id: nextId++, text, done: false });
}

function toggleTodo(id) {
    const todo = todos.value.find(t => t.id === id);
    if (todo) todo.done = !todo.done;
}

function deleteTodo(id) {
    todos.value = todos.value.filter(t => t.id !== id);
}
</script>
Vue — Child Component (TodoList)
<!-- Child: TodoList.vue -->
<template>
  <ul class="todo-list">
    <li
      v-for="todo in filteredTodos"
      :key="todo.id"
      :class="{ done: todo.done }"
    >
      <input
        type="checkbox"
        :checked="todo.done"
        @change="$emit('toggle', todo.id)"
      >
      <span>{{ todo.text }}</span>
      <button @click="$emit('delete', todo.id)">🗑️</button>
    </li>
    <li v-if="filteredTodos.length === 0" class="empty">
      Tidak ada todo 🎉
    </li>
  </ul>
</template>

<script setup>
import { computed } from 'vue';

// Terima props dari parent
const props = defineProps({
    todos: { type: Array, required: true },
    filter: { type: String, default: 'all' }
});

// Emit events ke parent
const emit = defineEmits(['toggle', 'delete']);

// Filter todo berdasarkan filter yang dipilih
const filteredTodos = computed(() => {
    switch (props.filter) {
        case 'active': return props.todos.filter(t => !t.done);
        case 'done': return props.todos.filter(t => t.done);
        default: return props.todos;
    }
});
</script>

<style scoped>
.todo-list { list-style: none; padding: 0; }
.todo-list li {
    display: flex;
    align-items: center;
    gap: 10px;
    padding: 10px;
    border-bottom: 1px solid #eee;
}
.todo-list li.done span {
    text-decoration: line-through;
    opacity: 0.6;
}
.todo-list li.empty {
    justify-content: center;
    color: #888;
    font-style: italic;
}
</style>

Slots — Content Distribution

Vue — Slots
<!-- Modal.vue — Komponen dengan slot -->
<template>
  <div class="modal-overlay" @click.self="$emit('close')">
    <div class="modal">
      <div class="modal-header">
        <h2><slot name="title">Judul Default</slot></h2>
        <button @click="$emit('close')">&times;</button>
      </div>
      <div class="modal-body">
        <!-- Default slot -->
        <slot>Konten default jika tidak ada slot</slot>
      </div>
      <div class="modal-footer">
        <slot name="footer">
          <button @click="$emit('close')">Tutup</button>
        </slot>
      </div>
    </div>
  </div>
</template>

<!-- Menggunakan Modal dengan slots -->
<Modal v-if="showModal" @close="showModal = false">
  <template #title>
    Konfirmasi Hapus
  </template>

  <p>Apakah Anda yakin ingin menghapus item ini?</p>

  <template #footer>
    <button @click="showModal = false">Batal</button>
    <button @click="confirmDelete">Hapus</button>
  </template>
</Modal>

8. Vue Router

Vue Router adalah library routing resmi untuk Vue.js. Vue Router memungkinkan Anda membangun SPA (Single Page Application) dengan navigasi tanpa reload halaman.

Instalasi & Konfigurasi

Bash — Instalasi Vue Router
# Instal Vue Router 4 (untuk Vue 3)
npm install vue-router@4
JavaScript — src/router/index.js
import { createRouter, createWebHistory } from 'vue-router';

// Import komponen halaman
import Home from '../views/Home.vue';
import About from '../views/About.vue';
import Products from '../views/Products.vue';
import ProductDetail from '../views/ProductDetail.vue';
import NotFound from '../views/NotFound.vue';

// Definisi routes
const routes = [
    {
        path: '/',
        name: 'Home',
        component: Home,
        meta: { title: 'Beranda' }
    },
    {
        path: '/about',
        name: 'About',
        component: About,
        meta: { title: 'Tentang Kami' }
    },
    {
        path: '/products',
        name: 'Products',
        component: Products,
        meta: { title: 'Produk' }
    },
    {
        // Dynamic route dengan parameter
        path: '/products/:id',
        name: 'ProductDetail',
        component: ProductDetail,
        props: true, // Pass params sebagai props
        meta: { title: 'Detail Produk' }
    },
    {
        // Lazy loading — komponen di-load saat diakses
        path: '/dashboard',
        name: 'Dashboard',
        component: () => import('../views/Dashboard.vue'),
        meta: { title: 'Dashboard', requiresAuth: true }
    },
    {
        // Catch-all 404
        path: '/:pathMatch(.*)*',
        name: 'NotFound',
        component: NotFound
    }
];

// Buat router instance
const router = createRouter({
    history: createWebHistory(), // Mode HTML5 History
    routes,
    scrollBehavior(to, from, savedPosition) {
        if (savedPosition) return savedPosition;
        return { top: 0 };
    }
});

// Navigation Guard — cek autentikasi
router.beforeEach((to, from, next) => {
    document.title = to.meta.title
        ? `${to.meta.title} | BeebaneLabs`
        : 'BeebaneLabs';

    if (to.meta.requiresAuth) {
        const isLoggedIn = localStorage.getItem('token');
        if (!isLoggedIn) {
            next({ name: 'Home' });
            return;
        }
    }
    next();
});

export default router;

Menggunakan Router di Komponen

Vue — Navigasi & Router View
<!-- App.vue -->
<template>
  <div id="app">
    <nav>
      <!-- RouterLink: navigasi tanpa reload halaman -->
      <router-link to="/">Beranda</router-link>
      <router-link to="/about">Tentang</router-link>
      <router-link to="/products">Produk</router-link>
      <router-link :to="{ name: 'Dashboard' }">Dashboard</router-link>
    </nav>

    <!-- RouterView: tempat komponen halaman dirender -->
    <router-view v-slot="{ Component }">
      <transition name="fade" mode="out-in">
        <component :is="Component" />
      </transition>
    </router-view>
  </div>
</template>

<!-- ProductDetail.vue — Menggunakan route params -->
<template>
  <div>
    <h1>Detail Produk #{{ id }}</h1>
    <!-- id berasal dari props: true di router -->
    <button @click="goBack">← Kembali</button>
  </div>
</template>

<script setup>
import { useRouter, useRoute } from 'vue-router';

// useRoute: mengakses informasi route saat ini
const route = useRoute();
const router = useRouter();

// Props dari router (karena props: true)
defineProps({ id: [String, Number] });

// Atau ambil dari route langsung:
// const id = route.params.id;
// const query = route.query.search;

function goBack() {
    router.back(); // Navigasi kembali
    // Atau: router.push('/products');
    // Atau: router.push({ name: 'Products' });
}
</script>

<style>
.fade-enter-active,
.fade-leave-active {
    transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
    opacity: 0;
}
</style>
Diagram: Alur Vue Router
  Browser URL              Vue Router             Komponen Halaman
    │                         │                         │
    │  /products/42           │                         │
    │ ──────────────────────► │                         │
    │                         │  Match route:           │
    │                         │  /products/:id          │
    │                         │ ──────────────────────► │
    │                         │                         │ ProductDetail
    │                         │                         │ { id: 42 }
    │                         │  ◄──── Component ────── │
    │  ◄──── render ──────── │                         │
    │                         │                         │
    │  Navigasi ke /about     │                         │
    │ ──────────────────────► │  beforeEach guard ✓     │
    │                         │  match: /about          │
    │                         │ ──────────────────────► │
    │                         │                         │ About.vue
    │  ◄──── render ──────── │  ◄──── Component ────── │

9. Studi Kasus: Aplikasi Todo Lengkap

Mari kita gabungkan semua konsep yang telah dipelajari ke dalam aplikasi Todo yang lebih lengkap — menggunakan localStorage untuk persistensi data.

Vue — TodoApp.vue (Studi Kasus)
<template>
  <div class="todo-app">
    <h1>📝 Aplikasi Todo</h1>

    <!-- Input todo baru -->
    <form @submit.prevent="addTodo" class="todo-form">
      <input
        v-model="newTodo"
        type="text"
        placeholder="Tambah todo baru..."
        class="todo-input"
      >
      <button type="submit" :disabled="!newTodo.trim()">
        Tambah
      </button>
    </form>

    <!-- Filter buttons -->
    <div class="filters">
      <button
        v-for="f in filters"
        :key="f.value"
        :class="{ active: currentFilter === f.value }"
        @click="currentFilter = f.value"
      >
        {{ f.label }} ({{ f.count }})
      </button>
    </div>

    <!-- Daftar todo -->
    <transition-group name="list" tag="ul" class="todo-list">
      <li
        v-for="todo in filteredTodos"
        :key="todo.id"
        :class="{ done: todo.done }"
      >
        <label class="todo-label">
          <input
            type="checkbox"
            v-model="todo.done"
          >
          <span v-if="editingId !== todo.id">{{ todo.text }}</span>
          <input
            v-else
            v-model="editText"
            @keyup.enter="saveEdit(todo)"
            @keyup.escape="cancelEdit"
            @blur="saveEdit(todo)"
            class="edit-input"
            ref="editInput"
          >
        </label>
        <div class="todo-actions">
          <button @click="startEdit(todo)" title="Edit">✏️</button>
          <button @click="deleteTodo(todo.id)" title="Hapus">🗑️</button>
        </div>
      </li>
    </transition-group>

    <p v-if="filteredTodos.length === 0" class="empty">
      Tidak ada todo di kategori ini 🎉
    </p>

    <!-- Statistik -->
    <div class="stats">
      <span>Total: {{ todos.length }}</span>
      <span>Selesai: {{ doneCount }}</span>
      <span>Sisa: {{ activeCount }}</span>
    </div>
  </div>
</template>

<script setup>
import { ref, computed, watch, nextTick } from 'vue';

// ===== STATE =====
const newTodo = ref('');
const currentFilter = ref('all');
const editingId = ref(null);
const editText = ref('');
const editInput = ref(null);

// Load dari localStorage
const savedTodos = localStorage.getItem('todos');
const todos = ref(savedTodos ? JSON.parse(savedTodos) : [
    { id: 1, text: 'Belajar Vue.js Composition API', done: true },
    { id: 2, text: 'Buat proyek portfolio', done: false },
    { id: 3, text: 'Deploy ke Vercel', done: false }
]);

let nextId = todos.value.length
    ? Math.max(...todos.value.map(t => t.id)) + 1
    : 1;

// ===== COMPUTED =====
const doneCount = computed(() => todos.value.filter(t => t.done).length);
const activeCount = computed(() => todos.value.filter(t => !t.done).length);

const filters = computed(() => [
    { value: 'all', label: 'Semua', count: todos.value.length },
    { value: 'active', label: 'Aktif', count: activeCount.value },
    { value: 'done', label: 'Selesai', count: doneCount.value }
]);

const filteredTodos = computed(() => {
    switch (currentFilter.value) {
        case 'active': return todos.value.filter(t => !t.done);
        case 'done': return todos.value.filter(t => t.done);
        default: return todos.value;
    }
});

// ===== WATCHERS =====
// Simpan ke localStorage setiap kali todos berubah
watch(todos, (newTodos) => {
    localStorage.setItem('todos', JSON.stringify(newTodos));
}, { deep: true });

// ===== METHODS =====
function addTodo() {
    const text = newTodo.value.trim();
    if (!text) return;
    todos.value.push({ id: nextId++, text, done: false });
    newTodo.value = '';
}

function deleteTodo(id) {
    todos.value = todos.value.filter(t => t.id !== id);
}

async function startEdit(todo) {
    editingId.value = todo.id;
    editText.value = todo.text;
    await nextTick();
    editInput.value?.focus();
}

function saveEdit(todo) {
    const text = editText.value.trim();
    if (text) {
        todo.text = text;
    }
    editingId.value = null;
}

function cancelEdit() {
    editingId.value = null;
}
</script>

<style scoped>
.todo-app { max-width: 600px; margin: 0 auto; padding: 20px; }
.todo-form { display: flex; gap: 8px; margin-bottom: 20px; }
.todo-input { flex: 1; padding: 10px; border-radius: 8px; border: 1px solid #ccc; }
.todo-list { list-style: none; padding: 0; }
.todo-list li {
    display: flex; justify-content: space-between; align-items: center;
    padding: 12px; border-bottom: 1px solid #eee;
}
.todo-list li.done span { text-decoration: line-through; opacity: 0.5; }
.filters { display: flex; gap: 8px; margin-bottom: 16px; }
.filters button.active { background: #42b883; color: white; }
.stats { display: flex; gap: 20px; margin-top: 16px; color: #888; }
.list-enter-active, .list-leave-active { transition: all 0.3s ease; }
.list-enter-from, .list-leave-to { opacity: 0; transform: translateX(30px); }
</style>

10. Quiz: Uji Pemahamanmu!

Setelah membaca tutorial di atas, jawablah 5 pertanyaan berikut untuk menguji pemahamanmu tentang Vue.js:

Pertanyaan 1: Apa perbedaan utama antara v-if dan v-show?

a) Tidak ada perbedaan, keduanya sama
b) v-if menambah/menghapus elemen dari DOM, v-show hanya mengubah CSS display
c) v-show lebih hemat memori dari v-if
d) v-if hanya bekerja di template, v-show di script

Pertanyaan 2: Bagaimana cara mengakses nilai dari ref() di dalam script?

a) Langsung: count
b) Menggunakan .value: count.value
c) Menggunakan .get(): count.get()
d) Menggunakan $: $count

Pertanyaan 3: Fungsi dari computed property dalam Vue adalah?

a) Mengirim data dari child ke parent component
b) Menjalankan side effect saat data berubah
c) Menghitung nilai yang di-cache otomatis dari data reaktif
d) Mendaftarkan event listener ke DOM

Pertanyaan 4: Arah komunikasi data antara Props dan Events?

a) Props: child → parent, Events: parent → child
b) Props: parent → child, Events: child → parent
c) Keduanya: parent → child
d) Keduanya: child → parent

Pertanyaan 5: Apa fungsi dari router-link di Vue Router?

a) Mengirim HTTP request ke server
b) Navigasi tanpa reload halaman (SPA navigation)
c) Meng-cache komponen untuk performa lebih baik
d) Membuat URL pendek untuk sharing ke social media