Master Vue.js from the ground up. Learn why Vue was created, understand core concepts like templates, reactive data, computed properties, watchers, components, directives, and composition API. Build a complete production-ready expense tracker app covering all Vue fundamentals with best practices.

Vue.js has emerged as one of the most approachable and powerful frontend frameworks for building interactive user interfaces. But why does Vue exist, and what makes it unique? Understanding Vue's philosophy and core concepts is essential for building scalable, maintainable web applications.
In this article, we'll explore Vue's history, understand why it was created, dive deep into core concepts, and build a complete production-ready expense tracker application that demonstrates all fundamental Vue patterns.
Before Vue, building interactive web applications had several challenges:
Evan You created Vue in 2014 to solve these problems:
Templates are the foundation of Vue components. They use familiar HTML syntax with 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 is at the heart of Vue. The ref function creates reactive data that automatically updates the 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 are reactive values derived from other reactive data. They're cached and only recompute when 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 to perform side effects when 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 are reusable, encapsulated pieces of UI. Vue uses Single File Components (SFC) for 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 from parent to child. Emits send events from child to 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 are special attributes that add behavior to 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 to run code at specific stages of a 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>The Composition API provides a more flexible way to organize component logic using 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 to pass content from parent to 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>Let's build a complete expense tracker application that demonstrates all 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 and focused
// ✅ Use meaningful component names
// ✅ Separate container and presentational components
// ✅ Use composition for shared logic
// ✅ Leverage TypeScript for type safety// ✅ Use ref for primitive values
// ✅ Use reactive for objects (when needed)
// ✅ Use computed for derived state
// ✅ Use watch for side effects
// ✅ Avoid unnecessary reactivity// ✅ Use v-show for frequently toggled elements
// ✅ Use v-if for conditionally rendered elements
// ✅ Lazy load components with defineAsyncComponent
// ✅ Use :key with v-for for proper list rendering
// ✅ Memoize expensive computations// ✅ Group related logic in composables
// ✅ Use consistent naming conventions
// ✅ Keep components in separate files
// ✅ Use TypeScript for type safety
// ✅ Document complex logic with comments// ✅ Test component behavior, not implementation
// ✅ Use Vue Test Utils for unit tests
// ✅ Test user interactions
// ✅ Mock external dependencies
// ✅ Aim for high coverage// ❌ Wrong - mutating prop
props.user.name = 'John';
// ✅ Correct - emit event to 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 not cleaned up
watch(searchQuery, async (newQuery) => {
const response = await fetch(`/api/search?q=${newQuery}`);
results.value = await response.json();
});
// ✅ Correct - watcher automatically cleaned up in setup
// Vue automatically stops watchers when component unmountsVue.js has revolutionized frontend development by providing a progressive, approachable framework that scales from simple widgets to complex applications. Understanding its core concepts—templates, reactivity, computed properties, watchers, components, and the Composition API—is fundamental to becoming a proficient Vue developer.
The expense tracker application we built demonstrates all these concepts in action. By mastering these fundamentals and following best practices, you'll be able to build scalable, maintainable Vue applications.
Key takeaways:
Next steps:
Vue is a journey, not a destination. Keep learning, building, and improving your skills.