Vue.js Tutorial for Beginners (2025)
Vue.js is a progressive JavaScript framework for building user interfaces. It's designed to be easy to learn, yet powerful enough for complex applications.
This tutorial takes you from zero to building real Vue 3 apps with the Composition API.
Why Learn Vue.js?
| Reason | Detail |
|---|---|
| Easy to learn | Gentle learning curve vs React/Angular |
| Excellent docs | Best-in-class official documentation |
| Composition API | Reusable, composable logic (Vue 3) |
| Reactivity | Fine-grained reactivity out of the box |
| Performance | ~40KB gzipped, fast virtual DOM |
| Community | 45k+ GitHub stars, thriving ecosystem |
| Job market | High demand, especially in Asia/Europe |
| Versatile | SPAs, SSR (Nuxt), static sites, micro-frontends |
Vue vs React vs Angular
| Feature | Vue 3 | React 18 | Angular 17 |
|---|---|---|---|
| Learning curve | Low | Medium | High |
| Language | HTML/CSS/JS | JSX + JS | TypeScript |
| Reactivity | Automatic | Manual hooks | Automatic (Signals) |
| Bundle size | ~40KB | ~45KB | ~75KB |
| State | Pinia | Redux/Zustand | NgRx/Services |
| Routing | Vue Router | React Router | Built-in |
| SSR | Nuxt | Next.js | Angular Universal |
| Backed by | Community | Meta | |
| Best for | Any size app | React-heavy teams | Enterprise |
1. Setup
Option A: CDN (no install needed)
<!DOCTYPE html>
<html>
<head>
<title>Vue App</title>
</head>
<body>
<div id="app">{{ message }}</div>
<script type="module">
import { createApp, ref } from 'https://unpkg.com/vue@3/dist/vue.esm-browser.js'
createApp({
setup() {
const message = ref('Hello, Vue!')
return { message }
}
}).mount('#app')
</script>
</body>
</html>
Option B: Vite (recommended for real projects)
npm create vue@latest my-app
# Choose: TypeScript? No JSX? No Vue Router? Yes Pinia? Yes Vitest? No
cd my-app
npm install
npm run dev
Your app runs at http://localhost:5173.
Project Structure
my-app/
├── src/
│ ├── assets/ # Static files (images, CSS)
│ ├── components/ # Reusable Vue components
│ ├── views/ # Page components (with Vue Router)
│ ├── router/ # Route definitions
│ │ └── index.ts
│ ├── stores/ # Pinia stores
│ ├── App.vue # Root component
│ └── main.ts # App entry point
├── public/ # Public assets (not processed by Vite)
├── index.html
├── package.json
└── vite.config.ts
2. Single File Components (SFC)
Vue apps are built with .vue files — each file has three parts:
<!-- src/components/HelloWorld.vue -->
<template>
<div class="hello">
<h1>{{ title }}</h1>
<p>Count: {{ count }}</p>
<button @click="increment">+1</button>
</div>
</template>
<script setup>
import { ref } from 'vue'
// Props
const props = defineProps({
title: {
type: String,
default: 'Hello, Vue!'
}
})
// Reactive state
const count = ref(0)
// Method
function increment() {
count.value++
}
</script>
<style scoped>
.hello {
padding: 20px;
text-align: center;
}
</style>
Three sections:
<template>— HTML structure (what to render)<script setup>— Composition API logic<style scoped>— CSS scoped to this component only
3. Reactivity: ref and reactive
Vue 3 reactivity is the heart of the framework.
ref — for primitive values
<script setup>
import { ref } from 'vue'
const count = ref(0)
const name = ref('Alice')
const isVisible = ref(true)
// In script: access via .value
console.log(count.value) // 0
count.value++
console.log(count.value) // 1
</script>
<template>
<!-- In template: no .value needed -->
<p>{{ count }}</p>
<p>{{ name }}</p>
<p v-if="isVisible">I am visible</p>
</template>
reactive — for objects
<script setup>
import { reactive } from 'vue'
const user = reactive({
name: 'Alice',
age: 30,
address: {
city: 'Paris'
}
})
// Access directly (no .value)
user.name = 'Bob'
user.address.city = 'London'
</script>
<template>
<p>{{ user.name }} lives in {{ user.address.city }}</p>
</template>
ref vs reactive
ref |
reactive |
|
|---|---|---|
| Use for | Primitives, any value | Objects, arrays |
| Access in script | .value required |
Direct access |
| Access in template | Auto-unwrapped | Direct access |
| Can reassign | Yes (x.value = newObj) |
No (loses reactivity) |
| Destructuring | Safe | Loses reactivity (use toRefs) |
Recommendation: Use ref for everything — simpler and consistent.
computed — derived reactive values
<script setup>
import { ref, computed } from 'vue'
const firstName = ref('Alice')
const lastName = ref('Smith')
// Computed value — auto-updates when dependencies change
const fullName = computed(() => `${firstName.value} ${lastName.value}`)
const price = ref(100)
const discount = ref(20)
const finalPrice = computed(() => price.value * (1 - discount.value / 100))
</script>
<template>
<p>Full name: {{ fullName }}</p>
<p>Final price: ${{ finalPrice.toFixed(2) }}</p>
</template>
watch — side effects on change
<script setup>
import { ref, watch, watchEffect } from 'vue'
const count = ref(0)
const query = ref('')
// watch — explicit dependency
watch(count, (newVal, oldVal) => {
console.log(`count changed from ${oldVal} to ${newVal}`)
})
// watch multiple sources
watch([count, query], ([newCount, newQuery]) => {
console.log('either changed:', newCount, newQuery)
})
// watchEffect — auto-tracks all accessed refs
watchEffect(() => {
// runs immediately, re-runs when query changes
console.log('searching for:', query.value)
})
</script>
4. Template Syntax
Text interpolation
<template>
<p>{{ message }}</p>
<p>{{ 1 + 1 }}</p>
<p>{{ user.name.toUpperCase() }}</p>
<p>{{ isLoggedIn ? 'Welcome!' : 'Please login' }}</p>
</template>
Directives — cheat sheet
| Directive | Purpose | Example |
|---|---|---|
v-bind:attr or :attr |
Bind attribute | :href="url" |
v-on:event or @event |
Event listener | @click="handler" |
v-model |
Two-way binding | v-model="inputVal" |
v-if / v-else-if / v-else |
Conditional render | v-if="isAdmin" |
v-show |
Toggle visibility | v-show="isOpen" |
v-for |
List rendering | v-for="item in list" |
v-once |
Render once | v-once |
v-html |
Raw HTML | v-html="htmlStr" |
v-pre |
Skip compilation | v-pre |
v-bind — attribute binding
<script setup>
const imgUrl = ref('/logo.png')
const isActive = ref(true)
const styles = ref({ color: 'red', fontSize: '16px' })
</script>
<template>
<!-- Bind attribute -->
<img :src="imgUrl" :alt="'Logo'" />
<!-- Bind class -->
<div :class="{ active: isActive, disabled: !isActive }">Text</div>
<!-- Multiple classes -->
<div :class="['base-class', isActive ? 'active' : '']">Text</div>
<!-- Bind style -->
<p :style="styles">Styled</p>
<p :style="{ color: 'blue', fontWeight: 'bold' }">Blue bold</p>
</template>
v-on — event handling
<script setup>
function handleClick() {
console.log('clicked!')
}
function handleInput(event) {
console.log(event.target.value)
}
function handleSubmit(event) {
event.preventDefault()
console.log('submitted!')
}
</script>
<template>
<!-- Basic event -->
<button @click="handleClick">Click me</button>
<!-- Inline handler -->
<button @click="count++">Increment</button>
<!-- Event with argument -->
<button @click="handleInput($event)">Input</button>
<!-- Event modifiers -->
<form @submit.prevent="handleSubmit">
<button type="submit">Submit</button>
</form>
<!-- Key modifiers -->
<input @keyup.enter="search" @keyup.esc="clear" />
<!-- Mouse modifiers -->
<div @click.right.prevent="showMenu">Right-click me</div>
</template>
Event modifiers:
| Modifier | Equivalent |
|---|---|
.prevent |
event.preventDefault() |
.stop |
event.stopPropagation() |
.once |
Fire once |
.capture |
Capture phase |
.self |
Only if target is element itself |
.passive |
Improves scroll performance |
v-model — two-way binding
<script setup>
import { ref } from 'vue'
const text = ref('')
const checked = ref(false)
const selected = ref('')
const multiSelect = ref([])
</script>
<template>
<!-- Text input -->
<input v-model="text" placeholder="Type here..." />
<p>You typed: {{ text }}</p>
<!-- Checkbox -->
<input type="checkbox" v-model="checked" />
<span>{{ checked }}</span>
<!-- Select -->
<select v-model="selected">
<option value="">Choose...</option>
<option value="vue">Vue</option>
<option value="react">React</option>
</select>
<!-- Multi-select (returns array) -->
<select v-model="multiSelect" multiple>
<option value="a">A</option>
<option value="b">B</option>
</select>
</template>
v-model modifiers:
<!-- Trim whitespace -->
<input v-model.trim="name" />
<!-- Cast to number -->
<input v-model.number="age" type="number" />
<!-- Update on change (not input) -->
<input v-model.lazy="text" />
v-if and v-show
<script setup>
const isLoggedIn = ref(true)
const userRole = ref('admin')
const items = ref([])
</script>
<template>
<!-- v-if: element removed from DOM -->
<div v-if="isLoggedIn">
<p v-if="userRole === 'admin'">Admin panel</p>
<p v-else-if="userRole === 'editor'">Editor panel</p>
<p v-else>Viewer panel</p>
</div>
<div v-else>
<p>Please log in</p>
</div>
<!-- v-show: element hidden with CSS (stays in DOM) -->
<p v-show="isLoggedIn">Visible when logged in</p>
<!-- Group elements with template -->
<template v-if="items.length > 0">
<h3>Items:</h3>
<ul>
<li v-for="item in items" :key="item.id">{{ item.name }}</li>
</ul>
</template>
<p v-else>No items found.</p>
</template>
v-if vs v-show:
v-if |
v-show |
|
|---|---|---|
| DOM | Removed/added | Always in DOM |
| CSS | N/A | display: none |
| Initial render | Lazy (skips if false) | Always renders |
| Toggle cost | Higher | Lower |
| Use when | Rarely toggles | Frequently toggles |
v-for — list rendering
<script setup>
const fruits = ref(['Apple', 'Banana', 'Cherry'])
const users = ref([
{ id: 1, name: 'Alice', role: 'admin' },
{ id: 2, name: 'Bob', role: 'user' },
])
const settings = ref({
theme: 'dark',
language: 'en',
notifications: true
})
</script>
<template>
<!-- Array -->
<ul>
<li v-for="(fruit, index) in fruits" :key="index">
{{ index + 1 }}. {{ fruit }}
</li>
</ul>
<!-- Array of objects — always use :key with unique id -->
<div v-for="user in users" :key="user.id">
<strong>{{ user.name }}</strong> — {{ user.role }}
</div>
<!-- Object -->
<ul>
<li v-for="(value, key) in settings" :key="key">
{{ key }}: {{ value }}
</li>
</ul>
<!-- Range -->
<span v-for="n in 5" :key="n">{{ n }} </span>
<!-- renders: 1 2 3 4 5 -->
</template>
5. Components
Creating and using components
<!-- src/components/UserCard.vue -->
<template>
<div class="card">
<img :src="avatar" :alt="name" />
<h3>{{ name }}</h3>
<p>{{ bio }}</p>
</div>
</template>
<script setup>
defineProps({
name: {
type: String,
required: true
},
avatar: {
type: String,
default: '/default-avatar.png'
},
bio: {
type: String,
default: ''
}
})
</script>
<!-- src/App.vue -->
<script setup>
import UserCard from './components/UserCard.vue'
</script>
<template>
<UserCard
name="Alice Smith"
avatar="/alice.jpg"
bio="Full-stack developer from Paris"
/>
<UserCard name="Bob Jones" />
</template>
Props — parent to child
<!-- Child component -->
<script setup>
// Simple props
const props = defineProps({
title: String,
count: {
type: Number,
default: 0
},
isActive: {
type: Boolean,
default: false
},
items: {
type: Array,
default: () => []
},
config: {
type: Object,
validator: (val) => val.theme !== undefined
}
})
// Access in script
console.log(props.title)
</script>
<template>
<h2>{{ title }}</h2>
<p>Count: {{ count }}</p>
</template>
Emits — child to parent
<!-- Child: src/components/SearchBar.vue -->
<script setup>
import { ref } from 'vue'
const emit = defineEmits(['search', 'clear'])
const query = ref('')
function handleSearch() {
emit('search', query.value)
}
function handleClear() {
query.value = ''
emit('clear')
}
</script>
<template>
<div>
<input v-model="query" @keyup.enter="handleSearch" placeholder="Search..." />
<button @click="handleSearch">Search</button>
<button @click="handleClear">Clear</button>
</div>
</template>
<!-- Parent -->
<script setup>
import SearchBar from './components/SearchBar.vue'
const results = ref([])
function onSearch(query) {
console.log('Searching for:', query)
// fetch results...
}
function onClear() {
results.value = []
}
</script>
<template>
<SearchBar @search="onSearch" @clear="onClear" />
</template>
Slots — flexible content
<!-- BaseCard.vue -->
<template>
<div class="card">
<!-- Named slot -->
<header class="card-header">
<slot name="header">Default Header</slot>
</header>
<!-- Default slot -->
<main class="card-body">
<slot>No content provided.</slot>
</main>
<!-- Named slot with fallback -->
<footer class="card-footer">
<slot name="footer">
<button>Close</button>
</slot>
</footer>
</div>
</template>
<!-- Using BaseCard -->
<template>
<BaseCard>
<template #header>
<h2>My Custom Header</h2>
</template>
<p>This goes into the default slot.</p>
<p>Multiple elements are fine.</p>
<template #footer>
<button @click="save">Save</button>
<button @click="cancel">Cancel</button>
</template>
</BaseCard>
</template>
6. Lifecycle Hooks
<script setup>
import { ref, onMounted, onUpdated, onUnmounted, onBeforeMount } from 'vue'
const data = ref(null)
onBeforeMount(() => {
// Before component is mounted (DOM not available yet)
console.log('Before mount')
})
onMounted(() => {
// Component is in the DOM — fetch data, start timers, access DOM
console.log('Mounted')
fetchData()
})
onUpdated(() => {
// After reactive data changed and DOM updated
console.log('Updated')
})
onUnmounted(() => {
// Cleanup — clear timers, cancel requests, remove listeners
console.log('Unmounted')
})
async function fetchData() {
const res = await fetch('https://api.example.com/data')
data.value = await res.json()
}
</script>
Lifecycle order:
| Hook | When | Use for |
|---|---|---|
onBeforeMount |
Before DOM creation | Rarely needed |
onMounted |
After DOM inserted | Fetch data, DOM access, timers |
onBeforeUpdate |
Before DOM re-render | Compare old/new DOM |
onUpdated |
After DOM re-render | Access updated DOM |
onBeforeUnmount |
Before cleanup | Prepare teardown |
onUnmounted |
After cleanup | Clear timers, cancel subscriptions |
7. Composables (Custom Hooks)
Composables are reusable logic functions — Vue's equivalent of React hooks.
// src/composables/useFetch.js
import { ref, watchEffect } from 'vue'
export function useFetch(url) {
const data = ref(null)
const loading = ref(false)
const error = ref(null)
async function fetchData() {
loading.value = true
error.value = null
try {
const res = await fetch(url.value ?? url)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
data.value = await res.json()
} catch (err) {
error.value = err.message
} finally {
loading.value = false
}
}
watchEffect(() => {
fetchData()
})
return { data, loading, error, refetch: fetchData }
}
<!-- Using the composable -->
<script setup>
import { useFetch } from '@/composables/useFetch'
const { data: posts, loading, error } = useFetch('https://jsonplaceholder.typicode.com/posts')
</script>
<template>
<div v-if="loading">Loading...</div>
<div v-else-if="error">Error: {{ error }}</div>
<ul v-else>
<li v-for="post in posts?.slice(0, 5)" :key="post.id">
{{ post.title }}
</li>
</ul>
</template>
More composable examples
// src/composables/useLocalStorage.js
import { ref, watch } from 'vue'
export function useLocalStorage(key, defaultValue) {
const stored = localStorage.getItem(key)
const value = ref(stored ? JSON.parse(stored) : defaultValue)
watch(value, (newVal) => {
localStorage.setItem(key, JSON.stringify(newVal))
}, { deep: true })
return value
}
// src/composables/useCounter.js
import { ref, computed } from 'vue'
export function useCounter(initialValue = 0, options = {}) {
const { min = -Infinity, max = Infinity } = options
const count = ref(initialValue)
const increment = () => { if (count.value < max) count.value++ }
const decrement = () => { if (count.value > min) count.value-- }
const reset = () => { count.value = initialValue }
const isAtMax = computed(() => count.value >= max)
const isAtMin = computed(() => count.value <= min)
return { count, increment, decrement, reset, isAtMax, isAtMin }
}
8. Vue Router
npm install vue-router@4
Define routes
// src/router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import Home from '@/views/Home.vue'
import About from '@/views/About.vue'
import UserProfile from '@/views/UserProfile.vue'
import NotFound from '@/views/NotFound.vue'
const routes = [
{ path: '/', component: Home },
{ path: '/about', component: About },
// Route with parameter
{ path: '/users/:id', component: UserProfile },
// Nested routes
{
path: '/dashboard',
component: () => import('@/views/Dashboard.vue'), // lazy load
children: [
{ path: '', component: () => import('@/views/DashboardHome.vue') },
{ path: 'settings', component: () => import('@/views/Settings.vue') },
]
},
// Redirect
{ path: '/home', redirect: '/' },
// Catch-all 404
{ path: '/:pathMatch(.*)*', name: 'NotFound', component: NotFound },
]
const router = createRouter({
history: createWebHistory(),
routes,
})
export default router
// src/main.js
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
createApp(App).use(router).mount('#app')
Navigation
<script setup>
import { useRouter, useRoute } from 'vue-router'
const router = useRouter()
const route = useRoute()
// Current route info
console.log(route.path) // '/users/42'
console.log(route.params.id) // '42'
console.log(route.query) // { tab: 'settings' }
// Programmatic navigation
function goHome() {
router.push('/')
}
function goToUser(id) {
router.push({ path: `/users/${id}` })
// or
router.push({ name: 'user', params: { id } })
}
function goBack() {
router.back()
}
</script>
<template>
<!-- RouterLink for declarative navigation -->
<nav>
<RouterLink to="/">Home</RouterLink>
<RouterLink to="/about">About</RouterLink>
<RouterLink :to="`/users/${userId}`">Profile</RouterLink>
<!-- Active class applied automatically -->
<RouterLink to="/dashboard" active-class="is-active" exact-active-class="is-exact">
Dashboard
</RouterLink>
</nav>
<!-- Router outlet -->
<RouterView />
</template>
Route guards
// Navigation guard — auth protection
router.beforeEach((to, from, next) => {
const isAuthenticated = !!localStorage.getItem('token')
if (to.meta.requiresAuth && !isAuthenticated) {
next('/login')
} else {
next()
}
})
// Per-route guard
const routes = [
{
path: '/admin',
component: AdminPanel,
meta: { requiresAuth: true, role: 'admin' },
beforeEnter: (to, from, next) => {
// component-specific guard
next()
}
}
]
9. Pinia (State Management)
Pinia is the official Vue state management library. Simple, intuitive, TypeScript-first.
npm install pinia
// src/main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
createApp(App).use(createPinia()).mount('#app')
Define a store
// src/stores/useCartStore.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
// Option 1: Composition API store (recommended)
export const useCartStore = defineStore('cart', () => {
// State
const items = ref([])
// Getters (computed)
const totalItems = computed(() => items.value.reduce((sum, item) => sum + item.qty, 0))
const totalPrice = computed(() =>
items.value.reduce((sum, item) => sum + item.price * item.qty, 0)
)
// Actions
function addItem(product) {
const existing = items.value.find(i => i.id === product.id)
if (existing) {
existing.qty++
} else {
items.value.push({ ...product, qty: 1 })
}
}
function removeItem(productId) {
items.value = items.value.filter(i => i.id !== productId)
}
function clearCart() {
items.value = []
}
return { items, totalItems, totalPrice, addItem, removeItem, clearCart }
})
<!-- Using the store -->
<script setup>
import { useCartStore } from '@/stores/useCartStore'
const cart = useCartStore()
const product = { id: 1, name: 'Vue Book', price: 29.99 }
</script>
<template>
<div>
<p>Items in cart: {{ cart.totalItems }}</p>
<p>Total: ${{ cart.totalPrice.toFixed(2) }}</p>
<button @click="cart.addItem(product)">Add to Cart</button>
<button @click="cart.clearCart()">Clear Cart</button>
<ul>
<li v-for="item in cart.items" :key="item.id">
{{ item.name }} × {{ item.qty }}
<button @click="cart.removeItem(item.id)">Remove</button>
</li>
</ul>
</div>
</template>
10. Handling HTTP Requests
Using fetch (built-in)
<script setup>
import { ref, onMounted } from 'vue'
const posts = ref([])
const loading = ref(false)
const error = ref(null)
onMounted(async () => {
loading.value = true
try {
const res = await fetch('https://jsonplaceholder.typicode.com/posts?_limit=5')
if (!res.ok) throw new Error('Request failed')
posts.value = await res.json()
} catch (err) {
error.value = err.message
} finally {
loading.value = false
}
})
</script>
<template>
<div v-if="loading">Loading posts...</div>
<div v-else-if="error" class="error">{{ error }}</div>
<ul v-else>
<li v-for="post in posts" :key="post.id">
<strong>{{ post.title }}</strong>
</li>
</ul>
</template>
Using axios
npm install axios
// src/services/api.js
import axios from 'axios'
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'https://api.example.com',
timeout: 10000,
})
// Request interceptor — add auth token
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token')
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
// Response interceptor — handle errors
api.interceptors.response.use(
(response) => response.data,
(error) => {
if (error.response?.status === 401) {
// redirect to login
}
return Promise.reject(error)
}
)
export default api
11. Forms with Validation
<script setup>
import { ref, reactive, computed } from 'vue'
const form = reactive({
name: '',
email: '',
password: '',
})
const errors = reactive({
name: '',
email: '',
password: '',
})
const submitted = ref(false)
function validateEmail(email) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
}
function validate() {
let isValid = true
errors.name = ''
errors.email = ''
errors.password = ''
if (!form.name.trim()) {
errors.name = 'Name is required'
isValid = false
} else if (form.name.length < 2) {
errors.name = 'Name must be at least 2 characters'
isValid = false
}
if (!form.email) {
errors.email = 'Email is required'
isValid = false
} else if (!validateEmail(form.email)) {
errors.email = 'Enter a valid email'
isValid = false
}
if (!form.password) {
errors.password = 'Password is required'
isValid = false
} else if (form.password.length < 8) {
errors.password = 'Password must be at least 8 characters'
isValid = false
}
return isValid
}
function handleSubmit() {
if (validate()) {
submitted.value = true
console.log('Form submitted:', form)
}
}
</script>
<template>
<form @submit.prevent="handleSubmit">
<div class="field">
<label>Name</label>
<input v-model="form.name" type="text" placeholder="Your name" />
<span class="error" v-if="errors.name">{{ errors.name }}</span>
</div>
<div class="field">
<label>Email</label>
<input v-model="form.email" type="email" placeholder="you@example.com" />
<span class="error" v-if="errors.email">{{ errors.email }}</span>
</div>
<div class="field">
<label>Password</label>
<input v-model="form.password" type="password" placeholder="Min 8 chars" />
<span class="error" v-if="errors.password">{{ errors.password }}</span>
</div>
<button type="submit">Register</button>
<p v-if="submitted" class="success">Registration successful!</p>
</form>
</template>
12. Project 1 — Todo App
<!-- src/App.vue -->
<script setup>
import { ref, computed } from 'vue'
const todos = ref([
{ id: 1, text: 'Learn Vue 3', done: true },
{ id: 2, text: 'Build an app', done: false },
])
const newTodo = ref('')
const filter = ref('all') // 'all' | 'active' | 'done'
const filteredTodos = computed(() => {
if (filter.value === 'active') return todos.value.filter(t => !t.done)
if (filter.value === 'done') return todos.value.filter(t => t.done)
return todos.value
})
const remaining = computed(() => todos.value.filter(t => !t.done).length)
function addTodo() {
if (!newTodo.value.trim()) return
todos.value.push({
id: Date.now(),
text: newTodo.value.trim(),
done: false,
})
newTodo.value = ''
}
function toggleTodo(id) {
const todo = todos.value.find(t => t.id === id)
if (todo) todo.done = !todo.done
}
function removeTodo(id) {
todos.value = todos.value.filter(t => t.id !== id)
}
function clearDone() {
todos.value = todos.value.filter(t => !t.done)
}
</script>
<template>
<div class="app">
<h1>Todo App</h1>
<form @submit.prevent="addTodo">
<input
v-model="newTodo"
placeholder="What needs to be done?"
autofocus
/>
<button type="submit">Add</button>
</form>
<div class="filters">
<button
v-for="f in ['all', 'active', 'done']"
:key="f"
:class="{ active: filter === f }"
@click="filter = f"
>
{{ f.charAt(0).toUpperCase() + f.slice(1) }}
</button>
</div>
<ul>
<li
v-for="todo in filteredTodos"
:key="todo.id"
:class="{ done: todo.done }"
>
<input type="checkbox" :checked="todo.done" @change="toggleTodo(todo.id)" />
<span>{{ todo.text }}</span>
<button @click="removeTodo(todo.id)">✕</button>
</li>
</ul>
<p>{{ remaining }} task{{ remaining !== 1 ? 's' : '' }} remaining</p>
<button v-if="todos.some(t => t.done)" @click="clearDone">
Clear completed
</button>
</div>
</template>
<style scoped>
.app { max-width: 500px; margin: 40px auto; padding: 0 20px; font-family: sans-serif; }
input[type="text"] { width: 100%; padding: 10px; margin-bottom: 10px; }
.filters button { margin-right: 8px; }
.filters button.active { font-weight: bold; }
li.done span { text-decoration: line-through; opacity: 0.5; }
li { display: flex; align-items: center; gap: 10px; padding: 8px 0; }
</style>
13. Project 2 — Weather App
<!-- src/App.vue -->
<script setup>
import { ref } from 'vue'
const city = ref('')
const weather = ref(null)
const loading = ref(false)
const error = ref('')
// Uses Open-Meteo geocoding + weather API (no API key needed)
async function fetchWeather() {
if (!city.value.trim()) return
loading.value = true
error.value = ''
weather.value = null
try {
// Step 1: Geocode city name
const geoRes = await fetch(
`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(city.value)}&count=1`
)
const geoData = await geoRes.json()
if (!geoData.results?.length) {
error.value = 'City not found'
return
}
const { latitude, longitude, name, country } = geoData.results[0]
// Step 2: Fetch weather
const weatherRes = await fetch(
`https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}` +
`¤t_weather=true&hourly=temperature_2m,precipitation_probability,weathercode` +
`&daily=temperature_2m_max,temperature_2m_min,weathercode,precipitation_sum` +
`&timezone=auto&forecast_days=5`
)
const weatherData = await weatherRes.json()
weather.value = {
city: name,
country,
current: weatherData.current_weather,
daily: weatherData.daily,
}
} catch (err) {
error.value = 'Failed to fetch weather. Try again.'
} finally {
loading.value = false
}
}
function getWeatherEmoji(code) {
if (code === 0) return '☀️'
if (code <= 3) return '⛅'
if (code <= 67) return '🌧️'
if (code <= 77) return '❄️'
if (code <= 82) return '🌦️'
return '⛈️'
}
function formatDate(dateStr) {
return new Date(dateStr).toLocaleDateString('en', { weekday: 'short', month: 'short', day: 'numeric' })
}
</script>
<template>
<div class="weather-app">
<h1>🌤️ Weather App</h1>
<form @submit.prevent="fetchWeather">
<input v-model="city" placeholder="Enter city name..." />
<button type="submit" :disabled="loading">
{{ loading ? 'Loading...' : 'Search' }}
</button>
</form>
<p v-if="error" class="error">{{ error }}</p>
<div v-if="weather" class="result">
<div class="current">
<h2>{{ weather.city }}, {{ weather.country }}</h2>
<div class="temp">
<span class="emoji">{{ getWeatherEmoji(weather.current.weathercode) }}</span>
<span class="degrees">{{ Math.round(weather.current.temperature) }}°C</span>
</div>
<p>Wind: {{ weather.current.windspeed }} km/h</p>
</div>
<div class="forecast">
<h3>5-Day Forecast</h3>
<div class="days">
<div
v-for="(date, i) in weather.daily.time"
:key="date"
class="day"
>
<div>{{ i === 0 ? 'Today' : formatDate(date) }}</div>
<div class="emoji">{{ getWeatherEmoji(weather.daily.weathercode[i]) }}</div>
<div>{{ Math.round(weather.daily.temperature_2m_max[i]) }}° / {{ Math.round(weather.daily.temperature_2m_min[i]) }}°</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.weather-app { max-width: 600px; margin: 40px auto; padding: 20px; font-family: sans-serif; }
input { padding: 10px; font-size: 16px; width: 70%; }
button { padding: 10px 20px; font-size: 16px; margin-left: 8px; }
.current { text-align: center; margin: 20px 0; }
.temp { display: flex; align-items: center; justify-content: center; gap: 10px; }
.emoji { font-size: 2rem; }
.degrees { font-size: 3rem; font-weight: bold; }
.days { display: flex; gap: 10px; justify-content: center; flex-wrap: wrap; }
.day { text-align: center; padding: 10px; background: #f0f0f0; border-radius: 8px; min-width: 80px; }
.error { color: red; }
</style>
14. Project 3 — Multi-Page App with Router + Pinia
# Create project with router + pinia
npm create vue@latest notes-app
# Select: Router: Yes, Pinia: Yes
// src/stores/useNotesStore.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useNotesStore = defineStore('notes', () => {
const notes = ref(JSON.parse(localStorage.getItem('notes') ?? '[]'))
const searchQuery = ref('')
const filteredNotes = computed(() => {
if (!searchQuery.value) return notes.value
const q = searchQuery.value.toLowerCase()
return notes.value.filter(n =>
n.title.toLowerCase().includes(q) ||
n.content.toLowerCase().includes(q)
)
})
function saveToStorage() {
localStorage.setItem('notes', JSON.stringify(notes.value))
}
function addNote(title, content) {
notes.value.unshift({
id: Date.now(),
title,
content,
createdAt: new Date().toISOString(),
})
saveToStorage()
}
function updateNote(id, title, content) {
const note = notes.value.find(n => n.id === id)
if (note) {
note.title = title
note.content = content
note.updatedAt = new Date().toISOString()
saveToStorage()
}
}
function deleteNote(id) {
notes.value = notes.value.filter(n => n.id !== id)
saveToStorage()
}
function getNoteById(id) {
return notes.value.find(n => n.id === Number(id))
}
return { notes, filteredNotes, searchQuery, addNote, updateNote, deleteNote, getNoteById }
})
// src/router/index.js
import { createRouter, createWebHistory } from 'vue-router'
export default createRouter({
history: createWebHistory(),
routes: [
{ path: '/', component: () => import('@/views/HomeView.vue') },
{ path: '/notes/new', component: () => import('@/views/NewNoteView.vue') },
{ path: '/notes/:id', component: () => import('@/views/NoteView.vue') },
{ path: '/notes/:id/edit', component: () => import('@/views/EditNoteView.vue') },
]
})
<!-- src/views/HomeView.vue -->
<script setup>
import { useNotesStore } from '@/stores/useNotesStore'
import { useRouter } from 'vue-router'
const store = useNotesStore()
const router = useRouter()
function formatDate(iso) {
return new Date(iso).toLocaleDateString('en', {
year: 'numeric', month: 'short', day: 'numeric'
})
}
</script>
<template>
<div>
<div class="header">
<h1>Notes</h1>
<RouterLink to="/notes/new">+ New Note</RouterLink>
</div>
<input v-model="store.searchQuery" placeholder="Search notes..." />
<div v-if="store.filteredNotes.length === 0">
<p>No notes yet. <RouterLink to="/notes/new">Create one!</RouterLink></p>
</div>
<div class="notes-grid">
<RouterLink
v-for="note in store.filteredNotes"
:key="note.id"
:to="`/notes/${note.id}`"
class="note-card"
>
<h3>{{ note.title }}</h3>
<p>{{ note.content.slice(0, 100) }}{{ note.content.length > 100 ? '...' : '' }}</p>
<small>{{ formatDate(note.createdAt) }}</small>
</RouterLink>
</div>
</div>
</template>
15. Common Mistakes
| Mistake | Problem | Fix |
|---|---|---|
Mutating reactive directly |
Loses reactivity | Use ref or update properties, don't reassign |
Forgetting .value |
ref not reactive in script |
Always use .value in <script setup> |
No :key in v-for |
Poor performance, bugs | Always add :key="unique-id" |
v-if + v-for on same element |
Unexpected behavior | Put v-if on wrapper <template> |
| Mutating props directly | Breaks one-way data flow | Emit event to parent, or use local copy |
| Watching reactive object shallowly | Nested changes missed | Add { deep: true } to watch |
Calling composables outside setup() |
Breaks reactivity context | Only call composables at top of setup |
Not cleaning up in onUnmounted |
Memory leaks, stale data | Clear timers, cancel subscriptions |
Vue vs Related Terms
| Term | What it is |
|---|---|
| Vue 3 | Latest major version — Composition API, <script setup>, Proxy-based reactivity |
| Vue 2 | Legacy version — Options API, Vue.observable, EOL 2023 |
| Vite | Fast build tool (replaces Vue CLI for new projects) |
| Nuxt.js | Vue framework for SSR, SSG, file-based routing |
| Pinia | Official Vue state management (replaces Vuex) |
| Vuex | Legacy state management for Vue 2/3 (use Pinia instead) |
| Vue Router | Official routing library |
| Composition API | Vue 3 way to write component logic (vs Options API) |
| Options API | Vue 2 style (data(), methods, computed, mounted) |
| SFC | Single File Component — .vue file with template/script/style |
Learning Path
| Stage | Timeline | Topics |
|---|---|---|
| 1. Foundations | Week 1–2 | HTML/CSS/JS ES6+, arrow functions, destructuring, promises |
| 2. Vue Basics | Week 3–4 | SFC, ref/reactive, directives, events, computed |
| 3. Components | Week 5–6 | Props, emits, slots, composables, lifecycle hooks |
| 4. Routing | Week 7 | Vue Router, params, guards, nested routes |
| 5. State | Week 8 | Pinia stores, getters, actions |
| 6. Real Projects | Week 9–12 | API integration, forms, validation, error handling |
| 7. Nuxt.js | Month 4+ | SSR, SSG, file-based routing, Nitro server |
Free Resources
| Resource | URL | Best for |
|---|---|---|
| Official Docs | vuejs.org | Comprehensive reference |
| Vue School | vueschool.io | Video courses |
| Vue Mastery | vuemastery.com | Structured learning |
| Pinia Docs | pinia.vuejs.org | State management |
| Vue Router Docs | router.vuejs.org | Routing |
| Nuxt Docs | nuxt.com | Full-stack Vue |
FAQ
Is Vue.js good for beginners?
Yes — Vue has the most gentle learning curve of the major frameworks. You can start with just HTML, add a Vue CDN link, and build interactive apps without a build step. The Composition API is intuitive once you understand ref.
Vue 2 or Vue 3?
Always Vue 3 for new projects. Vue 2 reached end-of-life on December 31, 2023 and no longer receives security updates. Vue 3 has better performance, TypeScript support, and the Composition API.
Options API or Composition API?
Use Composition API (<script setup>) — it's the modern Vue 3 approach. Better TypeScript support, more reusable logic through composables, and better tree-shaking. Options API still works and is fine for smaller projects.
Vue vs React — which should I learn?
Vue is easier to learn. React has a larger ecosystem and job market (especially in the US). Both are excellent choices. Vue is dominant in Asia and growing in Europe. Start with whichever excites you more.
Do I need Pinia for every Vue app?
No — ref and reactive in a composable is enough for small/medium apps. Add Pinia when you need to share state across many components that aren't in a parent-child relationship.
What's Nuxt.js and do I need it?
Nuxt.js adds Server-Side Rendering (SSR), Static Site Generation (SSG), file-based routing, and server API routes to Vue. Use it when SEO matters, for content-heavy sites, or for full-stack apps. For pure SPAs (dashboards, tools), plain Vue + Vite is fine.