Introduction to Vue.js
Vue.js is a progressive JavaScript framework for building user interfaces. Unlike monolithic frameworks, Vue is designed from the ground up to be incrementally adoptable — you can use it for a small interactive widget on a single page, or scale it up to power a complex single-page application (SPA). Created by Evan You in 2014, Vue has grown into one of the most popular frontend frameworks thanks to its gentle learning curve, excellent documentation, and powerful ecosystem.
Why Vue.js Matters
Vue matters because it strikes a rare balance between simplicity and power. It offers reactive data binding, a component-based architecture, and a rich ecosystem — all without forcing you into a rigid structure. The Composition API introduced in Vue 3 brings first-class TypeScript support and better logic reuse, making Vue a serious contender for enterprise applications.
- Progressive: Use as little or as much of the framework as you need.
- Reactive: The DOM updates automatically when your data changes.
- Component-based: Build encapsulated, reusable UI elements.
- Approachable: If you know HTML, CSS, and JavaScript, you can start building immediately.
- Performant: Vue 3's compiler produces highly optimized render functions.
Getting Started: Installation and Setup
The fastest way to start a Vue project is with the official scaffolding tool, create-vue. It sets up a modern build pipeline using Vite, which provides instant hot module replacement and a lightning-fast dev server.
# Create a new Vue project
npm create vue@latest my-vue-app
# Navigate into the project
cd my-vue-app
# Install dependencies
npm install
# Start the development server
npm run dev
The scaffolding wizard will ask about TypeScript, Vue Router, Pinia, and testing tools. For your first project, you can accept the defaults. Once the dev server is running, open the URL printed in your terminal — usually http://localhost:5173.
Project Structure Overview
my-vue-app/
├── index.html
├── package.json
├── vite.config.js
├── src/
│ ├── main.js
│ ├── App.vue
│ ├── components/
│ │ └── HelloWorld.vue
│ └── assets/
│ └── vue.svg
└── public/
└── vite.svg
The entry point is src/main.js, which creates the Vue application instance and mounts it to the DOM. The root component lives in src/App.vue.
Vue Components and Single File Components
Vue components are the building blocks of any Vue application. A Single File Component (SFC) uses the .vue extension and combines template, script, and styles in one file. This colocation keeps related concerns together and improves developer experience.
<template>
<div class="greeting">
<h1>{{ message }}</h1>
<button @click="increment">Clicked {{ count }} times</button>
</div>
</template>
<script setup>
import { ref } from 'vue'
const message = ref('Hello, Vue!')
const count = ref(0)
function increment() {
count.value++
}
</script>
<style scoped>
.greeting {
text-align: center;
padding: 2rem;
}
</style>
The <script setup> syntax is the recommended way to author components in Vue 3. It compiles to a render function and exposes top-level bindings to the template automatically. The scoped attribute on the style tag ensures styles only apply to this component.
Reactivity Fundamentals
Reactivity is the heart of Vue. When reactive state changes, Vue automatically updates the DOM. The two primary APIs for creating reactive state are ref and reactive.
Using ref
ref works with any value type, including primitives. You access or mutate the value through the .value property in JavaScript, but in templates the ref is automatically unwrapped.
<script setup>
import { ref } from 'vue'
const username = ref('guest')
const isActive = ref(false)
const items = ref(['apple', 'banana'])
function toggleActive() {
isActive.value = !isActive.value
}
function addItem() {
items.value.push('cherry') // mutation is reactive
}
</script>
<template>
<p>Welcome, {{ username }}!</p>
<p>Status: {{ isActive ? 'Active' : 'Inactive' }}</p>
<button @click="toggleActive">Toggle</button>
<ul>
<li v-for="item in items" :key="item">{{ item }}</li>
</ul>
</template>
Using reactive
reactive only works with objects (including arrays and collections). It returns a proxy of the original object, and you access properties directly without .value.
<script setup>
import { reactive } from 'vue'
const user = reactive({
name: 'Alice',
age: 30,
hobbies: ['reading', 'hiking']
})
function birthday() {
user.age++
}
</script>
A common best practice is to prefer ref for primitive values and reactive for grouped state objects. Avoid destructuring reactive objects directly, as it breaks reactivity — use toRefs instead.
Computed Properties and Watchers
Computed Properties
Computed properties derive values from reactive state and are cached until their dependencies change. They are ideal for transformations and calculations that should only re-run when inputs change.
<script setup>
import { ref, computed } from 'vue'
const price = ref(100)
const quantity = ref(3)
const taxRate = ref(0.08)
const subtotal = computed(() => price.value * quantity.value)
const tax = computed(() => subtotal.value * taxRate.value)
const total = computed(() => subtotal.value + tax.value)
</script>
<template>
<p>Subtotal: ${{ subtotal.toFixed(2) }}</p>
<p>Tax: ${{ tax.toFixed(2) }}</p>
<p>Total: ${{ total.toFixed(2) }}</p>
</template>
Watchers
Watchers let you perform side effects when reactive data changes. Use watch for specific sources and watchEffect for automatic dependency tracking.
<script setup>
import { ref, watch, watchEffect } from 'vue'
const searchQuery = ref('')
const results = ref([])
// watch: runs only when searchQuery changes
watch(searchQuery, (newValue, oldValue) => {
console.log(`Search changed from "${oldValue}" to "${newValue}"`)
performSearch(newValue)
})
// watchEffect: tracks all reactive deps used inside
watchEffect(() => {
console.log(`Current query is: ${searchQuery.value}`)
})
function performSearch(query) {
// Simulate an API call
results.value = query ? [`${query} result 1`, `${query} result 2`] : []
}
</script>
Template Syntax and Directives
Vue templates are HTML with special syntax that enables declarative rendering. The most common directives are v-bind, v-on, v-if, v-for, and v-model.
<template>
<!-- Text interpolation -->
<p>{{ title }}</p>
<!-- Attribute binding (shorthand: :href) -->
<a v-bind:href="url" :class="{ active: isActive }">Link</a>
<!-- Event binding (shorthand: @click) -->
<button v-on:click="handleClick">Click</button>
<input @keyup.enter="submit" />
<!-- Conditional rendering -->
<p v-if="isLoggedIn">Welcome back!</p>
<p v-else>Please log in.</p>
<!-- List rendering -->
<ul>
<li v-for="(item, index) in items" :key="item.id">
{{ index + 1 }}. {{ item.name }}
</li>
</ul>
<!-- Two-way binding -->
<input v-model="email" type="email" placeholder="Email" />
</template>
Always provide a unique :key attribute when using v-for. This helps Vue efficiently track and reorder elements, preventing subtle bugs when list items change.
Props and Emits: Component Communication
Components communicate through props (parent to child) and emits (child to parent). The defineProps and defineEmits macros are compiler macros available in <script setup> — they do not need to be imported.
Child Component
<!-- ChildComponent.vue -->
<script setup>
const props = defineProps({
title: {
type: String,
required: true
},
count: {
type: Number,
default: 0
}
})
const emit = defineEmits(['increment', 'reset'])
function handleIncrement() {
emit('increment', props.count + 1)
}
function handleReset() {
emit('reset')
}
</script>
<template>
<div class="child">
<h3>{{ title }}</h3>
<p>Count: {{ count }}</p>
<button @click="handleIncrement">Increment</button>
<button @click="handleReset">Reset</button>
</div>
</template>
Parent Component
<!-- ParentComponent.vue -->
<script setup>
import { ref } from 'vue'
import ChildComponent from './ChildComponent.vue'
const count = ref(0)
function onIncrement(newCount) {
count.value = newCount
}
function onReset() {
count.value = 0
}
</script>
<template>
<ChildComponent
title="Counter Widget"
:count="count"
@increment="onIncrement"
@reset="onReset"
/>
</template>
Composables: Reusable Logic
Composables are functions that encapsulate reactive logic for reuse across components. They follow a naming convention of use* and are the Vue 3 replacement for the Options API mixins pattern.
// composables/useMousePosition.js
import { ref, onMounted, onUnmounted } from 'vue'
export function useMousePosition() {
const x = ref(0)
const y = ref(0)
function update(event) {
x.value = event.pageX
y.value = event.pageY
}
onMounted(() => window.addEventListener('mousemove', update))
onUnmounted(() => window.removeEventListener('mousemove', update))
return { x, y }
}
<!-- MouseTracker.vue -->
<script setup>
import { useMousePosition } from './composables/useMousePosition'
const { x, y } = useMousePosition()
</script>
<template>
<p>Mouse position: ({{ x }}, {{ y }})</p>
</template>
Composables keep your components lean and your logic testable. They can be composed together, accept arguments, and return refs, functions, or any combination.
Routing with Vue Router
Vue Router is the official router for Vue. It maps URLs to components and supports nested routes, dynamic segments, navigation guards, and lazy loading.
// router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import Home from '../views/Home.vue'
const routes = [
{ path: '/', name: 'home', component: Home },
{
path: '/about',
name: 'about',
// Lazy-loaded route
component: () => import('../views/About.vue')
},
{
path: '/user/:id',
name: 'user',
component: () => import('../views/User.vue'),
props: true
}
]
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes
})
export default router
<!-- App.vue -->
<script setup>
import { RouterLink, RouterView } from 'vue-router'
</script>
<template>
<nav>
<RouterLink to="/">Home</RouterLink>
<RouterLink to="/about">About</RouterLink>
</nav>
<RouterView />
</template>
State Management with Pinia
Pinia is the official state management library for Vue, replacing Vuex. It offers a simpler API, full TypeScript support, and better devtools integration. A store is defined with defineStore and can hold state, getters, and actions.
// stores/counter.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useCounterStore = defineStore('counter', () => {
// State
const count = ref(0)
const name = ref('Counter')
// Getters
const doubleCount = computed(() => count.value * 2)
// Actions
function increment() {
count.value++
}
function reset() {
count.value = 0
}
return { count, name, doubleCount, increment, reset }
})
<!-- CounterView.vue -->
<script setup>
import { useCounterStore } from '../stores/counter'
const counter = useCounterStore()
</script>
<template>
<h2>{{ counter.name }}</h2>
<p>Count: {{ counter.count }}</p>
<p>Double: {{ counter.doubleCount }}</p>
<button @click="counter.increment">+</button>
<button @click="counter.reset">Reset</button>
</template>
Handling Forms and Validation
Vue's v-model directive makes two-way form binding straightforward. For validation, libraries like VeeValidate pair well with schema validators such as Yup or Zod.
<script setup>
import { ref, computed } from 'vue'
const form = ref({
email: '',
password: '',
confirmPassword: ''
})
const errors = computed(() => {
const e = {}
if (!form.value.email.includes('@')) e.email = 'Invalid email'
if (form.value.password.length < 8) e.password = 'Min 8 characters'
if (form.value.password !== form.value.confirmPassword) {
e.confirmPassword = 'Passwords do not match'
}
return e
})
const isValid = computed(() => Object.keys(errors.value).length === 0)
function submit() {
if (isValid.value) {
console.log('Form submitted:', form.value)
}
}
</script>
<template>
<form @submit.prevent="submit">
<div>
<label>Email</label>
<input v-model="form.email" type="email" />
<span v-if="errors.email">{{ errors.email }}</span>
</div>
<div>
<label>Password</label>
<input v-model="form.password" type="password" />
<span v-if="errors.password">{{ errors.password }}</span>
</div>
<div>
<label>Confirm Password</label>
<input v-model="form.confirmPassword" type="password" />
<span v-if="errors.confirmPassword">{{ errors.confirmPassword }}</span>
</div>
<button type="submit" :disabled="!isValid">Submit</button>
</form>
</template>
Fetching Data and Lifecycle Hooks
Vue provides lifecycle hooks that let you run code at specific stages of a component's existence. The most common are onMounted, onUpdated, and onUnmounted. For data fetching, onMounted is typically used.
<script setup>
import { ref, onMounted } from 'vue'
const posts = ref([])
const loading = ref(true)
const error = ref(null)
onMounted(async () => {
try {
const response = await fetch('https://jsonplaceholder.typicode.com/posts')
if (!response.ok) throw new Error('Network response was not ok')
posts.value = await response.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">Error: {{ error }}</div>
<ul v-else>
<li v-for="post in posts.slice(0, 10)" :key="post.id">
<h3>{{ post.title }}</h3>
<p>{{ post.body }}</p>
</li>
</ul>
</template>
Best Practices
- Use
<script setup>: It is the recommended syntax for Vue 3 components, producing cleaner and more performant code. - Prefer composables over mixins: Composables provide explicit, type-safe logic reuse without the naming collisions and unclear origins of mixins.
- Always key your
v-forloops: Use a stable unique identifier rather than the array index to avoid rendering bugs. - Keep components small and focused: A component should do one thing well. If it grows beyond a few hundred lines, consider splitting it.
- Use
computedover methods for derived state: Computed properties are cached and only re-evaluate when dependencies change. - Validate props: Always declare prop types and defaults to catch bugs early and improve self-documentation.
- Adopt TypeScript: Vue 3 was rewritten in TypeScript and offers excellent type inference. Adding types catches errors at compile time.
- Lazy-load routes: Use dynamic imports for route components to reduce the initial bundle size.
- Use Pinia for shared state: Avoid prop drilling by lifting shared state into a Pinia store.
- Handle errors gracefully: Show user-friendly error messages and log technical details for debugging.
Conclusion
Vue.js offers a uniquely approachable path into modern frontend development. Starting with simple template syntax and reactive data, you can progressively adopt more advanced features like composables, routing, and state management as your application grows. The Composition API, combined with the rich ecosystem of Vue Router, Pinia, and Vite, gives you everything you need to build maintainable, performant applications at any scale. By following the best practices outlined in this tutorial — favoring <script setup>, embracing composables, keeping components focused, and adopting TypeScript — you will be well equipped to take your Vue skills from beginner to expert. The best way to solidify this knowledge is to build: start a small project, iterate on it, and let real-world requirements guide your deeper exploration of the framework.