← Back to DevBytes

Vue.js from Beginner to Expert: A Learning Path

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.

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

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.

— Ad —

Google AdSense will appear here after approval

← Back to all articles