Kuasai Vue.js dari dasar. Pelajari mengapa Vue diciptakan, pahami core concepts seperti templates, reactive data, computed properties, watchers, components, directives, dan composition API. Bangun complete production-ready expense tracker app yang cover semua Vue fundamentals dengan best practices.

Vue.js telah menjadi salah satu frontend frameworks yang paling approachable dan powerful untuk building interactive user interfaces. Tapi mengapa Vue ada, dan apa yang membuatnya unique? Understanding Vue's philosophy dan core concepts adalah essential untuk building scalable, maintainable web applications.
Dalam artikel ini, kita akan explore Vue's history, understand mengapa Vue diciptakan, dive deep ke core concepts, dan build complete production-ready expense tracker application yang demonstrate semua fundamental Vue patterns.
Sebelum Vue, building interactive web applications memiliki beberapa challenges:
Evan You created Vue di 2014 untuk solve problems ini:
Templates adalah foundation dari Vue components. Mereka use familiar HTML syntax dengan Vue-specific directives.
<template>
<div class="greeting">
<h1>{{ message }}</h1>
<p>Welcome to {{ framework }}!</p>
</div>
</template>
<script setup>
const message = 'Hello Vue';
const framework = 'Vue.js';
</script>Reactivity adalah heart dari Vue. Function ref creates reactive data yang automatically updates UI.
<template>
<div>
<p>Count: {{ count }}</p>
<button @click="count++">Increment</button>
</div>
</template>
<script setup>
import { ref } from 'vue';
const count = ref(0);
</script>Computed properties adalah reactive values yang derived dari other reactive data. Mereka cached dan hanya recompute ketika dependencies change.
<template>
<div>
<p>First Name: {{ firstName }}</p>
<p>Last Name: {{ lastName }}</p>
<p>Full Name: {{ fullName }}</p>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
const firstName = ref('John');
const lastName = ref('Doe');
const fullName = computed(() => {
return `${firstName.value} ${lastName.value}`;
});
</script>Watchers allow you untuk perform side effects ketika reactive data changes.
<template>
<div>
<input v-model="searchQuery" placeholder="Search..." />
<p v-if="isSearching">Searching...</p>
<ul>
<li v-for="result in results" :key="result.id">{{ result.name }}</li>
</ul>
</div>
</template>
<script setup>
import { ref, watch } from 'vue';
const searchQuery = ref('');
const results = ref([]);
const isSearching = ref(false);
watch(searchQuery, async (newQuery) => {
if (!newQuery) {
results.value = [];
return;
}
isSearching.value = true;
try {
const response = await fetch(`/api/search?q=${newQuery}`);
results.value = await response.json();
} finally {
isSearching.value = false;
}
});
</script>Components adalah reusable, encapsulated pieces dari UI. Vue uses Single File Components (SFC) untuk organization.
<template>
<div class="card">
<h2>{{ title }}</h2>
<p>{{ description }}</p>
<button @click="handleClick">{{ buttonLabel }}</button>
</div>
</template>
<script setup>
defineProps({
title: String,
description: String,
buttonLabel: {
type: String,
default: 'Click me'
}
});
const emit = defineEmits(['action']);
const handleClick = () => {
emit('action');
};
</script>
<style scoped>
.card {
border: 1px solid #ddd;
padding: 20px;
border-radius: 8px;
}
</style>Props pass data dari parent ke child. Emits send events dari child ke parent.
<!-- Parent Component -->
<template>
<UserCard
:user="currentUser"
@update-user="handleUserUpdate"
/>
</template>
<script setup>
import { ref } from 'vue';
import UserCard from './UserCard.vue';
const currentUser = ref({ id: 1, name: 'Alice', email: 'alice@example.com' });
const handleUserUpdate = (updatedUser) => {
currentUser.value = updatedUser;
};
</script>
<!-- Child Component: UserCard.vue -->
<template>
<div class="user-card">
<h3>{{ user.name }}</h3>
<p>{{ user.email }}</p>
<button @click="updateUser">Update</button>
</div>
</template>
<script setup>
defineProps({
user: {
type: Object,
required: true
}
});
const emit = defineEmits(['update-user']);
const updateUser = () => {
emit('update-user', { ...props.user, name: 'Updated Name' });
};
const props = defineProps();
</script>Directives adalah special attributes yang add behavior ke elements.
<template>
<div>
<!-- v-if: Conditional rendering -->
<p v-if="isVisible">This is visible</p>
<p v-else>This is hidden</p>
<!-- v-for: List rendering -->
<ul>
<li v-for="item in items" :key="item.id">{{ item.name }}</li>
</ul>
<!-- v-model: Two-way binding -->
<input v-model="message" placeholder="Type something..." />
<p>You typed: {{ message }}</p>
<!-- v-on: Event handling -->
<button @click="handleClick">Click me</button>
<input @keyup.enter="handleEnter" />
<!-- v-bind: Attribute binding -->
<img :src="imageUrl" :alt="imageAlt" />
<div :class="{ active: isActive }"></div>
<!-- v-show: Display toggle (keeps in DOM) -->
<div v-show="showDetails">Details here</div>
</div>
</template>
<script setup>
import { ref } from 'vue';
const isVisible = ref(true);
const items = ref([
{ id: 1, name: 'Item 1' },
{ id: 2, name: 'Item 2' }
]);
const message = ref('');
const imageUrl = ref('/image.jpg');
const imageAlt = ref('An image');
const isActive = ref(false);
const showDetails = ref(false);
const handleClick = () => console.log('Clicked');
const handleEnter = () => console.log('Enter pressed');
</script>Lifecycle hooks allow you untuk run code di specific stages dari component's life.
<template>
<div>
<p>{{ data }}</p>
</div>
</template>
<script setup>
import { ref, onMounted, onUpdated, onUnmounted } from 'vue';
const data = ref(null);
// Runs after component is mounted to DOM
onMounted(() => {
console.log('Component mounted');
fetchData();
});
// Runs after component updates
onUpdated(() => {
console.log('Component updated');
});
// Runs before component is unmounted
onUnmounted(() => {
console.log('Component unmounted');
cleanup();
});
const fetchData = async () => {
try {
const response = await fetch('/api/data');
data.value = await response.json();
} catch (error) {
console.error('Error fetching data:', error);
}
};
const cleanup = () => {
// Clean up resources
};
</script>Composition API provides lebih flexible way untuk organize component logic menggunakan functions.
<template>
<div>
<p>Count: {{ count }}</p>
<button @click="increment">Increment</button>
<button @click="decrement">Decrement</button>
</div>
</template>
<script setup>
import { ref } from 'vue';
// Composable function
function useCounter(initialValue = 0) {
const count = ref(initialValue);
const increment = () => count.value++;
const decrement = () => count.value--;
const reset = () => count.value = initialValue;
return { count, increment, decrement, reset };
}
// Use the composable
const { count, increment, decrement } = useCounter(0);
</script>Slots allow you untuk pass content dari parent ke child components.
<!-- Parent Component -->
<template>
<Card>
<template #header>
<h2>Card Title</h2>
</template>
<p>This is the default slot content</p>
<template #footer>
<button>Action</button>
</template>
</Card>
</template>
<!-- Child Component: Card.vue -->
<template>
<div class="card">
<div class="card-header">
<slot name="header"></slot>
</div>
<div class="card-body">
<slot></slot>
</div>
<div class="card-footer">
<slot name="footer"></slot>
</div>
</div>
</template>
<style scoped>
.card {
border: 1px solid #ddd;
border-radius: 8px;
}
.card-header {
padding: 15px;
border-bottom: 1px solid #ddd;
}
.card-body {
padding: 15px;
}
.card-footer {
padding: 15px;
border-top: 1px solid #ddd;
}
</style>Mari kita build complete expense tracker application yang demonstrate semua Vue fundamentals.
expense-tracker/
├── src/
│ ├── components/
│ │ ├── ExpenseForm.vue
│ │ ├── ExpenseList.vue
│ │ ├── ExpenseItem.vue
│ │ └── ExpenseFilter.vue
│ ├── composables/
│ │ └── useExpenses.ts
│ ├── types/
│ │ └── expense.ts
│ ├── App.vue
│ └── App.css
├── package.json
└── vite.config.tsexport interface Expense {
id: string;
title: string;
amount: number;
category: 'food' | 'transport' | 'entertainment' | 'utilities' | 'other';
date: string;
description: string;
createdAt: Date;
}
export type FilterType = 'all' | 'week' | 'month';
export type CategoryType = Expense['category'];import { ref, computed } from 'vue';
import { Expense, FilterType } from '../types/expense';
export function useExpenses() {
const expenses = ref<Expense[]>([]);
const addExpense = (expense: Omit<Expense, 'id' | 'createdAt'>) => {
const newExpense: Expense = {
...expense,
id: Date.now().toString(),
createdAt: new Date(),
};
expenses.value.unshift(newExpense);
return newExpense;
};
const deleteExpense = (id: string) => {
expenses.value = expenses.value.filter((e) => e.id !== id);
};
const updateExpense = (id: string, updates: Partial<Expense>) => {
const index = expenses.value.findIndex((e) => e.id === id);
if (index !== -1) {
expenses.value[index] = { ...expenses.value[index], ...updates };
}
};
const getTotalExpenses = computed(() => {
return expenses.value.reduce((sum, e) => sum + e.amount, 0);
});
const getExpensesByCategory = computed(() => {
const categories: Record<string, number> = {};
expenses.value.forEach((e) => {
categories[e.category] = (categories[e.category] || 0) + e.amount;
});
return categories;
});
const getFilteredExpenses = (filter: FilterType) => {
const now = new Date();
const startOfWeek = new Date(now.setDate(now.getDate() - now.getDay()));
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
return expenses.value.filter((e) => {
const expenseDate = new Date(e.date);
if (filter === 'week') return expenseDate >= startOfWeek;
if (filter === 'month') return expenseDate >= startOfMonth;
return true;
});
};
return {
expenses,
addExpense,
deleteExpense,
updateExpense,
getTotalExpenses,
getExpensesByCategory,
getFilteredExpenses,
};
}<template>
<form @submit.prevent="handleSubmit" class="expense-form">
<div class="form-group">
<label for="title">Expense Title *</label>
<input
id="title"
v-model="form.title"
type="text"
placeholder="Enter expense title"
class="form-input"
required
/>
</div>
<div class="form-group">
<label for="amount">Amount *</label>
<input
id="amount"
v-model.number="form.amount"
type="number"
placeholder="0.00"
class="form-input"
step="0.01"
min="0"
required
/>
</div>
<div class="form-row">
<div class="form-group">
<label for="category">Category *</label>
<select
id="category"
v-model="form.category"
class="form-input"
required
>
<option value="food">Food</option>
<option value="transport">Transport</option>
<option value="entertainment">Entertainment</option>
<option value="utilities">Utilities</option>
<option value="other">Other</option>
</select>
</div>
<div class="form-group">
<label for="date">Date *</label>
<input
id="date"
v-model="form.date"
type="date"
class="form-input"
required
/>
</div>
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea
id="description"
v-model="form.description"
placeholder="Enter expense description"
class="form-input"
rows="3"
></textarea>
</div>
<button type="submit" class="btn btn-primary">Add Expense</button>
</form>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { Expense } from '../types/expense';
const emit = defineEmits<{
submit: [expense: Omit<Expense, 'id' | 'createdAt'>];
}>();
const form = ref({
title: '',
amount: 0,
category: 'food' as const,
date: new Date().toISOString().split('T')[0],
description: '',
});
const handleSubmit = () => {
if (!form.value.title.trim() || form.value.amount <= 0) {
alert('Please fill in all required fields');
return;
}
emit('submit', { ...form.value });
// Reset form
form.value = {
title: '',
amount: 0,
category: 'food',
date: new Date().toISOString().split('T')[0],
description: '',
};
};
</script>
<style scoped>
.expense-form {
margin-bottom: 30px;
padding-bottom: 30px;
border-bottom: 2px solid #e5e7eb;
}
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: 600;
color: #374151;
}
.form-input {
width: 100%;
padding: 10px 12px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 1rem;
transition: border-color 0.2s;
}
.form-input:focus {
outline: none;
border-color: #10b981;
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.1);
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 15px;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 6px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.btn-primary {
background: #10b981;
color: white;
width: 100%;
}
.btn-primary:hover {
background: #059669;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.4);
}
@media (max-width: 640px) {
.form-row {
grid-template-columns: 1fr;
}
}
</style><template>
<div class="expense-item">
<div class="expense-content">
<div class="expense-header">
<h3 class="expense-title">{{ expense.title }}</h3>
<span class="expense-amount">{{ formatCurrency(expense.amount) }}</span>
</div>
<p v-if="expense.description" class="expense-description">
{{ expense.description }}
</p>
<div class="expense-meta">
<span class="category-badge" :style="{ backgroundColor: getCategoryColor(expense.category) }">
{{ expense.category }}
</span>
<span class="expense-date">{{ formatDate(expense.date) }}</span>
</div>
</div>
<button @click="handleDelete" class="btn btn-danger btn-small">Delete</button>
</div>
</template>
<script setup lang="ts">
import { Expense } from '../types/expense';
defineProps<{
expense: Expense;
}>();
const emit = defineEmits<{
delete: [id: string];
}>();
const handleDelete = () => {
if (confirm('Are you sure you want to delete this expense?')) {
emit('delete', expense.id);
}
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(amount);
};
const formatDate = (date: string) => {
return new Date(date).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
};
const getCategoryColor = (category: string) => {
const colors: Record<string, string> = {
food: '#f59e0b',
transport: '#3b82f6',
entertainment: '#8b5cf6',
utilities: '#ef4444',
other: '#6b7280',
};
return colors[category] || '#6b7280';
};
</script>
<style scoped>
.expense-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px;
background: #f9fafb;
border-radius: 8px;
border-left: 4px solid #10b981;
transition: all 0.2s;
}
.expense-item:hover {
background: #f3f4f6;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.expense-content {
flex: 1;
}
.expense-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.expense-title {
font-size: 1rem;
font-weight: 600;
color: #111827;
margin: 0;
}
.expense-amount {
font-size: 1.1rem;
font-weight: 700;
color: #10b981;
}
.expense-description {
font-size: 0.875rem;
color: #6b7280;
margin: 0 0 8px 0;
}
.expense-meta {
display: flex;
gap: 10px;
align-items: center;
}
.category-badge {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
color: white;
font-size: 0.75rem;
font-weight: 600;
}
.expense-date {
font-size: 0.875rem;
color: #6b7280;
}
.btn {
padding: 6px 12px;
border: none;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.btn-danger {
background: #ef4444;
color: white;
}
.btn-danger:hover {
background: #dc2626;
}
.btn-small {
padding: 6px 12px;
}
@media (max-width: 640px) {
.expense-item {
flex-direction: column;
align-items: flex-start;
}
.btn {
width: 100%;
}
}
</style><template>
<div class="expense-filter">
<div class="filter-buttons">
<button
v-for="filterOption in filters"
:key="filterOption"
@click="$emit('filter-change', filterOption)"
:class="['filter-btn', { active: activeFilter === filterOption }]"
>
{{ filterOption.charAt(0).toUpperCase() + filterOption.slice(1) }}
</button>
</div>
<div class="filter-stats">
<div class="stat">
<span class="stat-label">Total:</span>
<span class="stat-value">{{ formatCurrency(stats.total) }}</span>
</div>
<div class="stat">
<span class="stat-label">Count:</span>
<span class="stat-value">{{ stats.count }}</span>
</div>
<div class="stat">
<span class="stat-label">Average:</span>
<span class="stat-value">{{ formatCurrency(stats.average) }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { FilterType } from '../types/expense';
defineProps<{
activeFilter: FilterType;
stats: {
total: number;
count: number;
average: number;
};
}>();
defineEmits<{
'filter-change': [filter: FilterType];
}>();
const filters: FilterType[] = ['all', 'week', 'month'];
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(amount);
};
</script>
<style scoped>
.expense-filter {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 25px;
padding: 15px;
background: #f9fafb;
border-radius: 8px;
}
.filter-buttons {
display: flex;
gap: 10px;
}
.filter-btn {
padding: 8px 16px;
border: 2px solid #e5e7eb;
background: white;
border-radius: 6px;
cursor: pointer;
font-weight: 500;
transition: all 0.2s;
}
.filter-btn.active {
border-color: #10b981;
background: #10b981;
color: white;
}
.filter-stats {
display: flex;
gap: 20px;
}
.stat {
display: flex;
flex-direction: column;
align-items: center;
}
.stat-label {
font-size: 0.75rem;
color: #6b7280;
font-weight: 600;
}
.stat-value {
font-size: 1rem;
font-weight: 700;
color: #111827;
}
@media (max-width: 640px) {
.expense-filter {
flex-direction: column;
gap: 15px;
align-items: flex-start;
}
.filter-stats {
width: 100%;
justify-content: space-around;
}
}
</style><template>
<div class="expense-list">
<div v-if="filteredExpenses.length === 0" class="empty-state">
<p>No expenses found. {{ filter !== 'all' ? 'Try changing the filter.' : 'Add your first expense!' }}</p>
</div>
<div v-else class="expenses">
<ExpenseItem
v-for="expense in filteredExpenses"
:key="expense.id"
:expense="expense"
@delete="$emit('delete', $event)"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { Expense, FilterType } from '../types/expense';
import ExpenseItem from './ExpenseItem.vue';
const props = defineProps<{
expenses: Expense[];
filter: FilterType;
}>();
defineEmits<{
delete: [id: string];
}>();
const filteredExpenses = computed(() => {
const now = new Date();
const startOfWeek = new Date(now);
startOfWeek.setDate(now.getDate() - now.getDay());
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
return props.expenses.filter((expense) => {
const expenseDate = new Date(expense.date);
if (props.filter === 'week') {
return expenseDate >= startOfWeek;
}
if (props.filter === 'month') {
return expenseDate >= startOfMonth;
}
return true;
});
});
</script>
<style scoped>
.expense-list {
min-height: 200px;
}
.expenses {
display: flex;
flex-direction: column;
gap: 12px;
}
.empty-state {
text-align: center;
padding: 40px 20px;
color: #9ca3af;
}
</style><template>
<div class="app">
<header class="app-header">
<h1>Expense Tracker</h1>
<p>Track your expenses efficiently</p>
</header>
<main class="app-main">
<div class="container">
<ExpenseForm @submit="handleAddExpense" />
<ExpenseFilter
:active-filter="filter"
:stats="stats"
@filter-change="filter = $event"
/>
<ExpenseList
:expenses="expenses"
:filter="filter"
@delete="handleDeleteExpense"
/>
</div>
</main>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue';
import { useExpenses } from './composables/useExpenses';
import { FilterType, Expense } from './types/expense';
import ExpenseForm from './components/ExpenseForm.vue';
import ExpenseFilter from './components/ExpenseFilter.vue';
import ExpenseList from './components/ExpenseList.vue';
import './App.css';
const { expenses, addExpense, deleteExpense } = useExpenses();
const filter = ref<FilterType>('all');
const stats = computed(() => {
const filtered = expenses.value.filter((e) => {
const now = new Date();
const startOfWeek = new Date(now);
startOfWeek.setDate(now.getDate() - now.getDay());
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
const expenseDate = new Date(e.date);
if (filter.value === 'week') return expenseDate >= startOfWeek;
if (filter.value === 'month') return expenseDate >= startOfMonth;
return true;
});
const total = filtered.reduce((sum, e) => sum + e.amount, 0);
const count = filtered.length;
const average = count > 0 ? total / count : 0;
return { total, count, average };
});
const handleAddExpense = (expense: Omit<Expense, 'id' | 'createdAt'>) => {
addExpense(expense);
};
const handleDeleteExpense = (id: string) => {
deleteExpense(id);
};
</script>
<style scoped>
.app {
min-height: 100vh;
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
padding: 20px;
}
.app-header {
text-align: center;
color: white;
margin-bottom: 40px;
}
.app-header h1 {
font-size: 2.5rem;
margin-bottom: 10px;
}
.app-header p {
font-size: 1.1rem;
opacity: 0.9;
}
.app-main {
max-width: 800px;
margin: 0 auto;
}
.container {
background: white;
border-radius: 12px;
padding: 30px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
@media (max-width: 640px) {
.app-header h1 {
font-size: 1.8rem;
}
.container {
padding: 20px;
}
}
</style>* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Scrollbar Styling */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #f1f5f9;
}
::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
/* Animations */
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
/* Utility Classes */
.transition-all {
transition: all 0.3s ease;
}
.shadow-sm {
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.shadow-md {
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.shadow-lg {
box-shadow: 0 10px 15px rgba(0, 0, 0, 0.1);
}
/* Focus Styles */
button:focus,
input:focus,
select:focus,
textarea:focus {
outline: 2px solid transparent;
outline-offset: 2px;
}
/* Responsive Typography */
@media (max-width: 768px) {
body {
font-size: 14px;
}
}
@media (max-width: 480px) {
body {
font-size: 13px;
}
}// ✅ Keep components small dan focused
// ✅ Use meaningful component names
// ✅ Separate container dan presentational components
// ✅ Use composition untuk shared logic
// ✅ Leverage TypeScript untuk type safety// ✅ Use ref untuk primitive values
// ✅ Use reactive untuk objects (ketika needed)
// ✅ Use computed untuk derived state
// ✅ Use watch untuk side effects
// ✅ Avoid unnecessary reactivity// ✅ Use v-show untuk frequently toggled elements
// ✅ Use v-if untuk conditionally rendered elements
// ✅ Lazy load components dengan defineAsyncComponent
// ✅ Use :key dengan v-for untuk proper list rendering
// ✅ Memoize expensive computations// ✅ Group related logic di composables
// ✅ Use consistent naming conventions
// ✅ Keep components di separate files
// ✅ Use TypeScript untuk type safety
// ✅ Document complex logic dengan comments// ✅ Test component behavior, bukan implementation
// ✅ Use Vue Test Utils untuk unit tests
// ✅ Test user interactions
// ✅ Mock external dependencies
// ✅ Aim untuk high coverage// ❌ Wrong - mutating prop
props.user.name = 'John';
// ✅ Correct - emit event ke parent
const emit = defineEmits(['update-user']);
emit('update-user', { ...props.user, name: 'John' });// ❌ Wrong - missing dependency
watch(() => {
console.log(count.value);
});
// ✅ Correct - include dependency
watch(() => count.value, () => {
console.log(count.value);
});// ❌ Wrong - using index as key
<div v-for="(item, index) in items" :key="index">{{ item }}</div>
// ✅ Correct - using unique identifier
<div v-for="item in items" :key="item.id">{{ item.name }}</div>// ❌ Wrong - forgetting .value
const count = ref(0);
count++; // This doesn't work
// ✅ Correct - using .value
const count = ref(0);
count.value++; // This works// ❌ Wrong - watcher tidak cleaned up
watch(searchQuery, async (newQuery) => {
const response = await fetch(`/api/search?q=${newQuery}`);
results.value = await response.json();
});
// ✅ Correct - watcher automatically cleaned up di setup
// Vue automatically stops watchers ketika component unmountsVue.js telah revolutionize frontend development dengan providing progressive, approachable framework yang scales dari simple widgets ke complex applications. Understanding core concepts-nya—templates, reactivity, computed properties, watchers, components, dan Composition API—adalah fundamental untuk menjadi proficient Vue developer.
Expense tracker application yang kita build demonstrate semua concepts ini di action. Dengan mastering fundamentals ini dan following best practices, kamu akan able untuk build scalable, maintainable Vue applications.
Key takeaways:
Next steps:
Vue adalah journey, bukan destination. Keep learning, building, dan improving your skills.