Vue.js is a progressive JavaScript framework for building user interfaces. “Progressive” means you can use as much or as little of it as you need — add it to a single page with a <script> tag, or build a full SPA with the Composition API, Vue Router, and Pinia.
Vue 3 with the Composition API is the current standard. This guide focuses on that.
Project Setup
npm create vite@latest my-app -- --template vue
cd my-app && npm install && npm run dev
Vite provides instant hot module reload during development and fast production builds.
Reactive Templates
A Single-File Component (.vue) packages template, script, and style together. The <script setup> syntax is the idiomatic Composition API form — variables declared here are automatically available in the template:
<!-- Counter.vue -->
<template>
<div>
<p>Count: {{ count }}</p>
<p>Double: {{ double }}</p>
<button @click="increment">+1</button>
<button @click="reset">Reset</button>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const count = ref(0)
// computed — automatically re-evaluates when count changes
const double = computed(() => count.value * 2)
const increment = () => count.value++
const reset = () => { count.value = 0 }
</script>
ref(0) creates a reactive value. Inside <script setup> you access it as count.value; in the template, Vue unwraps it automatically so you just write {{ count }}.
computed vs watch: Use computed when you need a derived value (something that transforms existing state). Use watch when you need a side effect (fetching data, logging, calling an API) in response to state changes:
<script setup>
import { ref, watch } from 'vue'
const searchQuery = ref('')
const results = ref([])
// watch triggers a side effect when searchQuery changes
watch(searchQuery, async (newQuery, oldQuery) => {
if (!newQuery) {
results.value = []
return
}
const data = await fetch(`/api/search?q=${newQuery}`).then(r => r.json())
results.value = data
})
</script>
Components: Props and Emits
Components receive data via props and communicate upward via emits. This one-way data flow makes components predictable:
<!-- UserCard.vue -->
<template>
<div class="card">
<h3>{{ user.name }}</h3>
<p>{{ user.email }}</p>
<button @click="$emit('edit', user.id)">Edit</button>
<button @click="$emit('delete', user.id)">Delete</button>
</div>
</template>
<script setup>
// defineProps and defineEmits are compiler macros — no import needed
const props = defineProps({
user: {
type: Object,
required: true,
},
})
const emit = defineEmits(['edit', 'delete'])
</script>
Using the component:
<!-- UserList.vue -->
<template>
<UserCard
v-for="user in users"
:key="user.id"
:user="user"
@edit="handleEdit"
@delete="handleDelete"
/>
</template>
<script setup>
import { ref } from 'vue'
import UserCard from './UserCard.vue'
const users = ref([
{ id: 1, name: 'Alice', email: '[email protected]' },
{ id: 2, name: 'Bob', email: '[email protected]' },
])
const handleEdit = (id) => console.log('edit', id)
const handleDelete = (id) => {
users.value = users.value.filter(u => u.id !== id)
}
</script>
Always provide :key on v-for — Vue uses it to efficiently update the DOM when the list changes.
Composables: Reusable Logic
The Composition API’s biggest advantage is composables — functions that encapsulate stateful logic and can be shared across components:
// composables/useAsync.js
import { ref } from 'vue'
export function useAsync(asyncFn) {
const data = ref(null)
const loading = ref(false)
const error = ref(null)
const execute = async (...args) => {
loading.value = true
error.value = null
try {
data.value = await asyncFn(...args)
} catch (err) {
error.value = err.message
} finally {
loading.value = false
}
}
return { data, loading, error, execute }
}
<!-- UserProfile.vue -->
<template>
<div v-if="loading">Loading...</div>
<div v-else-if="error">Error: {{ error }}</div>
<div v-else-if="data">
<h1>{{ data.name }}</h1>
<p>{{ data.email }}</p>
</div>
</template>
<script setup>
import { onMounted } from 'vue'
import { useAsync } from '@/composables/useAsync'
const props = defineProps({ userId: String })
const { data, loading, error, execute } = useAsync(
(id) => fetch(`/api/users/${id}`).then(r => r.json())
)
onMounted(() => execute(props.userId))
</script>
Composables replace mixins — they’re explicit about what they provide and don’t pollute the component namespace.
Vue Router
Install and configure routing:
npm install vue-router
// router/index.js
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{ path: '/', component: () => import('@/pages/Home.vue') },
{ path: '/users', component: () => import('@/pages/UserList.vue') },
{ path: '/users/:id', component: () => import('@/pages/UserDetail.vue') },
{
path: '/admin',
component: () => import('@/pages/Admin.vue'),
meta: { requiresAuth: true },
},
]
const router = createRouter({
history: createWebHistory(),
routes,
})
// Navigation guard — runs before every route
router.beforeEach((to, from) => {
if (to.meta.requiresAuth && !isLoggedIn()) {
return { path: '/login', query: { redirect: to.fullPath } }
}
})
export default router
<!-- App.vue — navigation and router outlet -->
<template>
<nav>
<RouterLink to="/">Home</RouterLink>
<RouterLink to="/users">Users</RouterLink>
</nav>
<RouterView />
</template>
The () => import(...) syntax enables code splitting — each route’s component is loaded only when navigated to, keeping the initial bundle small.
Pinia: State Management
For state shared across multiple components that aren’t parent-child related, use Pinia (Vue 3’s official state management library):
npm install pinia
// stores/users.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useUsersStore = defineStore('users', () => {
const users = ref([])
const loading = ref(false)
const activeUsers = computed(() => users.value.filter(u => u.active))
async function fetchUsers() {
loading.value = true
try {
users.value = await fetch('/api/users').then(r => r.json())
} finally {
loading.value = false
}
}
function removeUser(id) {
users.value = users.value.filter(u => u.id !== id)
}
return { users, loading, activeUsers, fetchUsers, removeUser }
})
<script setup>
import { onMounted } from 'vue'
import { useUsersStore } from '@/stores/users'
const store = useUsersStore()
onMounted(() => store.fetchUsers())
</script>
<template>
<div v-if="store.loading">Loading...</div>
<UserCard
v-for="user in store.activeUsers"
:key="user.id"
:user="user"
@delete="store.removeUser"
/>
</template>
Pinia stores are reactive — any component that reads store state will re-render when that state changes.
Summary
ref()for reactive primitives,computed()for derived values,watch()for side effects- Props flow down, emits go up — keep data flow one-directional
- Composables extract and reuse stateful logic across components
- Vue Router with lazy-loaded routes keeps the initial bundle small
- Pinia for shared state that doesn’t fit the parent-child component relationship
Comments