Today I want to tell you about Vue.js, a framework that’s gaining more and more popularity in the web development world. Let’s try to understand what it is, how it works, and why it might be the right choice for your projects.
Introduction
Vue.js is a progressive JavaScript framework used to build user interfaces and single-page applications. It was created by Evan You, a former Google employee who worked on AngularJS. The first version of Vue was released in 2014, and since then it has seen steady growth in the developer community.
Why choose Vue.js? Well, there are several reasons. First, it’s very lightweight, weighing about 20KB when compressed. Then it’s reactive, meaning it automatically responds to data changes by updating the DOM. It’s also incredibly flexible and can be used both for small parts of an existing application and to build entire complex applications. And perhaps most importantly, it’s relatively easy to learn compared to other frameworks.
Comparing Vue with React and Angular, we see that each has its pros and cons. React is very popular and has a huge ecosystem, but its freedom can become a problem for beginners who might feel overwhelmed by too many choices. Angular is complete and robust, but has a steep learning curve and might seem excessive for smaller projects. Vue tries to balance these two approaches, offering both flexibility and structure, depending on the needs.
1. Fundamental Concepts of Vue.js
1.1 The Reactive Approach
Reactivity is at the heart of Vue. In practice, when data changes, the view updates automatically. In Vue 2, this was achieved using Object.defineProperty()
to intercept read and write operations on object properties. In Vue 3, a more modern approach was adopted using JavaScript Proxy
, which offers better performance and covers more use cases.
For example, in Vue 3 we can create reactive data with reactive()
or ref()
:
import { reactive, ref } from 'vue'
// With reactive
const state = reactive({
count: 0,
message: 'Hello'
})
// With ref
const count = ref(0)
const message = ref('Hello')
When you modify state.count
or count.value
, Vue automatically updates any part of the interface that depends on these values. This is the heart of Vue’s reactivity.
1.2 The Vue Instance and the Options API
In Vue 3, we start by creating an application with createApp
:
import { createApp } from 'vue'
import App from './App.vue'
const app = createApp(App)
app.mount('#app')
The Options API is the traditional approach in Vue, where we define a component using an object with various options:
export default {
data() {
return {
message: 'Hello world',
counter: 0
}
},
methods: {
increment() {
this.counter++
}
},
computed: {
doubleCounter() {
return this.counter * 2
}
},
watch: {
counter(newValue, oldValue) {
console.log(`The counter changed from ${oldValue} to ${newValue}`)
}
}
}
Vue also offers several lifecycle hooks that allow us to execute code at specific moments during a component’s life:
created
: called after the instance has been createdmounted
: called after the component has been mounted in the DOMupdated
: called after the component has been updatedunmounted
: called after the component has been removed from the DOM
1.3 Template Syntax and Directives
Vue uses an HTML-based template system with some extensions. Interpolation is the simplest way to display data in templates:
<div>{{ message }}</div>
But Vue also offers several directives that add special functionality to HTML elements:
v-if
for conditional renderingv-for
for iterating over arrays or objectsv-bind
(or:
) for assigning values to attributesv-on
(or@
) for listening to eventsv-model
for two-way binding
Here’s an example using some of these directives:
<div>
<p v-if="showParagraph">This paragraph is visible</p>
<ul>
<li v-for="item in items" :key="item.id">{{ item.text }}</li>
</ul>
<button @click="addItem">Add Item</button>
<input v-model="newItem" placeholder="New item">
</div>
Vue also allows you to add classes and styles dynamically:
<div :class="{ active: isActive, 'text-danger': hasError }"></div>
<div :style="{ color: textColor, fontSize: fontSize + 'px' }"></div>
2. Components in Vue.js
2.1 Creating and Using Components
Components are the fundamental building block of Vue applications. In Vue 3, we can define a component in several ways. One of the most modern is using the Composition API with defineComponent
and setup
:
import { defineComponent, ref } from 'vue'
export default defineComponent({
props: {
title: String
},
setup(props, { emit }) {
const count = ref(0)
function increment() {
count.value++
emit('incremented', count.value)
}
return {
count,
increment
}
}
})
Props are used to pass data from a parent component to a child. The child component must declare which props it accepts:
export default {
props: {
title: String,
likes: {
type: Number,
default: 0
},
isPublished: {
type: Boolean,
required: true
}
}
}
For child-to-parent communication, Vue uses custom events. A child component can emit an event with $emit
(or emit
in the Composition API), and the parent component can listen to it:
<!-- Child component -->
<button @click="$emit('increment')">Increment</button>
<!-- Parent component -->
<MyCounter @increment="handleIncrement" />
Slots are another important mechanism for component composition, allowing content to be injected from the parent component to the child:
<!-- MyCard.vue component -->
<div class="card">
<div class="card-header">
<slot name="header">Default header</slot>
</div>
<div class="card-body">
<slot>Default content</slot>
</div>
<div class="card-footer">
<slot name="footer"></slot>
</div>
</div>
<!-- Usage -->
<MyCard>
<template #header>Card title</template>
This is the main content
<template #footer>Footer</template>
</MyCard>
2.2 State Management in Components
Each component can manage its own local state. With the Composition API, we use ref
and reactive
to create reactive state:
import { ref, reactive } from 'vue'
export default {
setup() {
const count = ref(0)
const user = reactive({
name: 'John',
age: 30
})
function increment() {
count.value++
}
function updateName(newName) {
user.name = newName
}
return {
count,
user,
increment,
updateName
}
}
}
It’s important to understand the difference between props and internal state. Props are data passed from a parent component and should not be directly modified by the child component. Internal state is completely controlled by the component itself.
Vue also offers v-model
for two-way binding, making it easy to synchronize forms and data:
<input v-model="message">
<p>The message is: {{ message }}</p>
2.3 Dynamic and Async Components
Vue allows you to dynamically change the displayed component using <component>
with the :is
directive:
<component :is="currentComponent"></component>
To improve performance, we can also load components asynchronously only when needed:
import { defineAsyncComponent } from 'vue'
const AsyncComponent = defineAsyncComponent(() =>
import('./components/AsyncComponent.vue')
)
3. Advanced State Management
3.1 Pinia (Modern State Management)
Pinia is the modern solution for state management in Vue, conceived as a successor to Vuex. It’s lighter, more typed (with TypeScript), and integrates perfectly with the Composition API.
Here’s how to define a store with Pinia:
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', {
state: () => ({
count: 0,
name: 'Counter'
}),
getters: {
doubleCount: (state) => state.count * 2
},
actions: {
increment() {
this.count++
},
async fetchSomeData() {
// async actions
}
}
})
And then we can use it in our components:
import { useCounterStore } from '@/stores/counter'
export default {
setup() {
const counterStore = useCounterStore()
function incrementAndPrint() {
counterStore.increment()
console.log(counterStore.doubleCount)
}
return { counterStore, incrementAndPrint }
}
}
3.2 Vuex (For Legacy Applications)
Vuex was the standard state manager for Vue 2, and many legacy applications still use it. It’s based on the concepts of state, mutations, actions, and getters:
import { createStore } from 'vuex'
export default createStore({
state: {
count: 0
},
mutations: {
increment(state) {
state.count++
}
},
actions: {
incrementAsync({ commit }) {
setTimeout(() => {
commit('increment')
}, 1000)
}
},
getters: {
doubleCount: state => state.count * 2
}
})
The main difference with Pinia is that Vuex is more verbose and uses a more rigid unidirectional flow where state can only be modified through mutations.
4. Vue Router (Route Management)
4.1 Basic Configuration
Vue Router is the official solution for routing in Vue applications. To get started, we need to install and configure it:
import { createRouter, createWebHistory } from 'vue-router'
import Home from './views/Home.vue'
import About from './views/About.vue'
const routes = [
{ path: '/', component: Home },
{ path: '/about', component: About }
]
const router = createRouter({
history: createWebHistory(),
routes
})
// And then in our app.js
app.use(router)
In the template, we can use <router-link>
to navigate and <router-view>
to display the component corresponding to the current route:
<nav>
<router-link to="/">Home</router-link>
<router-link to="/about">About</router-link>
</nav>
<router-view></router-view>
We can also navigate programmatically:
// Navigate to a new URL
router.push('/about')
// Navigate with parameters
router.push({ name: 'user', params: { userId: '123' } })
4.2 Dynamic Routes and Nested Routes
Vue Router supports dynamic parameters in routes:
const routes = [
{ path: '/user/:id', component: User }
]
In the User component, we can access the id
parameter with this.$route.params.id
in the Options API or useRoute().params.id
in the Composition API.
Route guards are functions that run before, during, or after navigation. They’re useful for access control:
router.beforeEach((to, from) => {
if (to.meta.requiresAuth && !isAuthenticated) {
return '/login'
}
})
Vue Router also supports nested routes:
const routes = [
{
path: '/user/:id',
component: User,
children: [
{ path: 'profile', component: UserProfile },
{ path: 'posts', component: UserPosts }
]
}
]
4.3 Lazy Loading Routes
To improve performance, we can load components only when needed:
const routes = [
{ path: '/about', component: () => import('./views/About.vue') }
]
This is particularly useful for large applications, where initial loading might be slow.
5. Composition API (Vue 3)
5.1 Why Use the Composition API?
The Composition API was introduced in Vue 3 and represents a fundamental change in how we write components. While the Options API organizes code by type (data, methods, computed, etc.), the Composition API allows you to organize it by logical functionality.
The main advantage is increased code reuse. With the Options API, extracting reusable logic could be complicated and required patterns like mixins that often caused naming conflicts and maintenance issues. The Composition API solves this problem by allowing you to extract features into “composables” that can be imported and used in any component.
Additionally, it offers better organization of logic. In complex components, related logic often ended up scattered across different options in the Options API. With the Composition API, we can group all the code related to a specific feature, making the component easier to read and maintain.
// With Options API
export default {
data() {
return { count: 0, name: 'Mario' }
},
methods: {
increment() { this.count++ }
},
computed: {
doubleCount() { return this.count * 2 }
}
}
// With Composition API
import { ref, computed } from 'vue'
export default {
setup() {
// Counter logic
const count = ref(0)
const doubleCount = computed(() => count.value * 2)
function increment() { count.value++ }
// User logic
const name = ref('Mario')
return { count, doubleCount, increment, name }
}
}
5.2 ref
vs reactive
In Vue 3, we have two main APIs for creating reactive state: ref
and reactive
.
ref
is used to create a reactive reference to a single value. It works with any type of value, primitive or object, and encapsulates the value in an object with a .value
property.
const count = ref(0) // For primitives
const user = ref({ name: 'Mario' }) // Also for objects
console.log(count.value) // 0
count.value++ // Update
reactive
, on the other hand, is used for objects and makes all properties of the object reactive in depth. Unlike ref
, it doesn’t require .value
to access or modify properties.
const state = reactive({
count: 0,
user: { name: 'Mario' }
})
console.log(state.count) // 0
state.count++ // Direct update
When to use one or the other? In general:
- Use
ref
for primitive values (numbers, strings, booleans) - Use
ref
when you need to pass a reactive variable to a function - Use
reactive
for complex objects when you want to access properties directly without.value
To convert between the two formats, Vue provides the utilities toRef
and toRefs
:
// From reactive to ref
const state = reactive({ count: 0 })
const countRef = toRef(state, 'count')
countRef.value++ // Also updates state.count
// Convert all properties of a reactive object to refs
const { count, user } = toRefs(state)
count.value++ // Also updates state.count
5.3 Logic Composition (Composables)
Composables are one of the most powerful features of the Composition API. These are functions that encapsulate and reuse stateful logic, similar to React hooks.
A typical composable:
- Uses composition APIs (
ref
,reactive
,computed
, etc.) - Has a function that starts with “use” by convention
- Returns an object with exposed values and functions
Here are some examples:
// useFetch.js - a composable for making HTTP requests
import { ref, watchEffect } from 'vue'
export function useFetch(url) {
const data = ref(null)
const error = ref(null)
const loading = ref(true)
async function fetchData() {
loading.value = true
try {
const response = await fetch(url)
data.value = await response.json()
} catch (e) {
error.value = e
} finally {
loading.value = false
}
}
watchEffect(() => {
fetchData()
})
return { data, error, loading, refetch: fetchData }
}
// useStorage.js - a composable for localStorage
import { ref, watch } from 'vue'
export function useStorage(key, defaultValue = null) {
const value = ref(JSON.parse(localStorage.getItem(key)) || defaultValue)
watch(value, (newValue) => {
localStorage.setItem(key, JSON.stringify(newValue))
})
return value
}
And then use them in components:
import { useFetch } from './composables/useFetch'
import { useStorage } from './composables/useStorage'
export default {
setup() {
// Using multiple composables in the same component
const { data: users, loading } = useFetch('https://api.example.com/users')
const darkMode = useStorage('darkMode', false)
return { users, loading, darkMode }
}
}
6. Optimization and Performance
6.1 Virtual DOM and Reactivity Optimization
Vue uses a Virtual DOM to efficiently update the DOM. Instead of directly updating the DOM when data changes, Vue creates a virtual representation of the DOM in memory and then compares this representation with the actual DOM, applying only the necessary changes.
This process, called “diffing,” drastically reduces costly DOM operations. Vue 3 has also improved this mechanism with:
- Static flags that mark parts that will never change
- Hoisting of static properties
- Tree-shaking to remove unused code
An important aspect is the optimal use of the key
prop in v-for
directives. Providing a unique and stable key for each element helps Vue identify which elements have changed, been added, or removed:
<!-- Not optimal: using index as key -->
<li v-for="(item, index) in items" :key="index">{{ item.name }}</li>
<!-- Optimal: using a unique ID as key -->
<li v-for="item in items" :key="item.id">{{ item.name }}</li>
6.2 Lazy Loading and Code Splitting
For large applications, loading all code at once can cause long loading times. Vue supports lazy loading of components and routes to split the application into smaller pieces (code splitting) that are loaded only when needed.
For components:
// Component loaded only when used
const LazyComponent = defineAsyncComponent(() =>
import('./components/HeavyComponent.vue')
)
For routes in Vue Router:
const routes = [
{
path: '/dashboard',
component: () => import('./views/Dashboard.vue')
}
]
This approach significantly reduces the initial loading time of the application.
6.3 Memoization with computed
and watch
Vue offers powerful tools to optimize expensive calculations:
computed
properties are calculated based on reactive dependencies and are memoized. They are recalculated only when one of their dependencies changes:
const list = ref([1, 2, 3, 4])
// Calculated once and memoized
const doubledList = computed(() => {
console.log('Calculating doubledList')
return list.value.map(n => n * 2)
})
// Does not recalculate if list doesn't change
console.log(doubledList.value) // [2, 4, 6, 8]
console.log(doubledList.value) // [2, 4, 6, 8] (no calculation)
list.value.push(5) // Now recalculates
console.log(doubledList.value) // [2, 4, 6, 8, 10] (recalculated)
watch
allows you to observe and react to state changes:
// Simple watch
watch(searchQuery, (newValue) => {
fetchResults(newValue)
})
// Advanced options
watch(searchQuery, (newValue) => {
fetchResults(newValue)
}, {
deep: true, // Observe deep changes in objects
immediate: true, // Execute immediately on creation
flush: 'post' // Execute after DOM update
})
7. Testing in Vue.js
7.1 Unit Testing with Vitest or Jest
Testing is a fundamental part of development with Vue. The official @vue/test-utils
library provides utilities for testing Vue components.
Here’s an example of testing with Vitest (the modern replacement for Jest in the Vue ecosystem):
import { mount } from '@vue/test-utils'
import { test, expect, vi } from 'vitest'
import Counter from './Counter.vue'
test('increments the counter when clicked', async () => {
const wrapper = mount(Counter, {
props: {
initialCount: 0
}
})
expect(wrapper.text()).toContain('0')
await wrapper.find('button').trigger('click')
expect(wrapper.text()).toContain('1')
})
test('emits events when requested', async () => {
const wrapper = mount(Counter)
await wrapper.find('.reset-btn').trigger('click')
expect(wrapper.emitted()).toHaveProperty('reset')
expect(wrapper.emitted().reset[0]).toEqual([0])
})
To mock props, events, or external dependencies:
// Mocking props
const wrapper = mount(UserProfile, {
props: {
user: { id: 1, name: 'Mario' }
}
})
// Mocking global events
const $router = {
push: vi.fn()
}
const wrapper = mount(NavBar, {
global: {
mocks: {
$router
}
}
})
7.2 E2E Testing with Cypress or Playwright
End-to-end tests simulate user behavior and test the entire application. Two popular tools are Cypress and Playwright.
Example with Cypress:
describe('App E2E', () => {
it('logs in and navigates to the dashboard', () => {
cy.visit('/')
cy.get('input[name=email]').type('user@example.com')
cy.get('input[name=password]').type('password123')
cy.get('button[type=submit]').click()
// Verify that navigation works
cy.url().should('include', '/dashboard')
cy.contains('Welcome, User').should('be.visible')
})
})
Example with Playwright:
test('adds a product to the cart', async ({ page }) => {
await page.goto('https://myshop.com')
await page.click('text=Products')
await page.click('text=Smartphone XYZ')
await page.click('button:has-text("Add to Cart")')
const cartCount = await page.locator('.cart-count').textContent()
expect(cartCount).toBe('1')
})
8. Vue.js in Production
8.1 Building with Vite
Vite has become the default build tool for new Vue 3 projects, replacing the webpack-based Vue CLI. It’s significantly faster thanks to the use of native ES modules during development.
The basic configuration of a Vite project for Vue is simple:
// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
// Additional configurations
resolve: {
alias: {
'@': '/src' // Shorthand for paths
}
},
// Production optimizations
build: {
minify: 'terser',
cssCodeSplit: true,
rollupOptions: {
output: {
manualChunks: {
vendor: ['vue', 'vue-router', 'pinia']
}
}
}
}
})
To start a Vite project:
npm create vite@latest my-vue-app -- --template vue
cd my-vue-app
npm install
npm run dev
8.2 SSR (Server-Side Rendering) with Nuxt.js
Nuxt.js is a framework based on Vue that simplifies the creation of server-side rendered applications. It offers an improved development experience with ready-to-use features like file-based routing, auto-import, and SSR.
The advantages of SSR include:
- Better SEO since search engines see fully rendered content
- Improved perceived performance, with faster initial load time
- Better user experience, especially on devices with slow connections
A basic Nuxt 3 project:
// nuxt.config.ts
export default defineNuxtConfig({
app: {
head: {
title: 'My Nuxt App',
meta: [
{ name: 'description', content: 'Description of my app' }
]
}
},
modules: [
'@pinia/nuxt',
'@nuxtjs/tailwindcss'
],
runtimeConfig: {
apiSecret: '', // accessible only on server-side
public: {
apiBase: '' // accessible also on client-side
}
}
})
8.3 Static Site Generation (SSG)
Vue also offers tools for static site generation:
VuePress is primarily designed for technical documentation. It generates pre-rendered static HTML pages that load as an SPA once in the browser.
// .vuepress/config.js
module.exports = {
title: 'My Documentation',
description: 'Documents for my project',
themeConfig: {
navbar: [
{ text: 'Home', link: '/' },
{ text: 'Guide', link: '/guide/' }
],
sidebar: {
'/guide/': [
'getting-started',
'configuration'
]
}
}
}
VitePress is the successor to VuePress based on Vite, faster and with modern features:
// .vitepress/config.js
export default {
title: 'My Documentation',
description: 'Powered by VitePress',
themeConfig: {
nav: [
{ text: 'Home', link: '/' },
{ text: 'Guide', link: '/guide/' }
],
sidebar: [
{
text: 'Guide',
items: [
{ text: 'Introduction', link: '/guide/introduction' }
]
}
]
}
}
9. Best Practices and Common Patterns
Project Organization
A well-organized project structure is essential for maintainability:
src/
├── assets/ # Static assets (images, fonts, etc.)
├── components/ # Reusable components
│ ├── ui/ # Generic UI components
│ └── features/ # Feature-specific components
├── composables/ # Composables (reusable logic)
├── layouts/ # Application layouts
├── router/ # Router configuration
├── stores/ # Pinia stores
├── utils/ # Utility and helper functions
└── views/ # Page components
Naming Conventions
Adopting consistent naming conventions improves readability:
- Components: PascalCase for both file names and registration (e.g.,
UserProfile.vue
) - Composables: camelCase with “use” prefix (e.g.,
useUserData.js
) - Custom directives: kebab-case with “v-” prefix (e.g.,
v-click-outside
) - Props: camelCase in definition, kebab-case in template (e.g.,
userName
/user-name
)
Error Handling
Robust error handling is crucial for production applications:
// Composable for error handling
export function useErrorHandling() {
const error = ref(null)
function handleError(e, customMessage = '') {
console.error(e)
error.value = {
message: customMessage || e.message,
original: e
}
// Potential integration with logging system
// logErrorToService(e)
}
function clearError() {
error.value = null
}
return { error, handleError, clearError }
}
// Usage in component
const { error, handleError } = useErrorHandling()
async function fetchData() {
try {
// Operation that might fail
const data = await api.get('/users')
return data
} catch (e) {
handleError(e, 'Unable to load users')
return []
}
}
10. Vue.js Ecosystem
Vue has a rich ecosystem of libraries that extend its capabilities:
UI Frameworks
- Quasar: Complete framework with support for web, mobile, and desktop
- Vuetify: Material Design framework with over 80 components
- PrimeVue: Collection of rich UI components
// Example with Vuetify
createApp(App)
.use(vuetify)
.mount('#app')
// In a component
<template>
<v-card>
<v-card-title>Card Title</v-card-title>
<v-card-text>Card content</v-card-text>
<v-card-actions>
<v-btn color="primary">OK</v-btn>
</v-card-actions>
</v-card>
</template>
Form Handling
- VeeValidate: Complete and flexible form validation
- FormKit: All-in-one system for forms with validation and UI
// VeeValidate example
import { Form, Field, ErrorMessage } from 'vee-validate'
import * as yup from 'yup'
const schema = yup.object({
email: yup.string().required().email(),
password: yup.string().required().min(8)
})
// In template
<Form :validation-schema="schema">
<Field name="email" type="email" />
<ErrorMessage name="email" />
<Field name="password" type="password" />
<ErrorMessage name="password" />
<button type="submit">Login</button>
</Form>
Animations
- vue-use/motion: Simple API for animations
- GSAP: Powerful animation library with Vue integration
// vue-use/motion example
import { useMotion } from '@vueuse/motion'
export default {
setup() {
const blockRef = ref(null)
const motionInstance = useMotion(blockRef, {
initial: { opacity: 0, y: 100 },
enter: {
opacity: 1,
y: 0,
transition: { duration: 800 }
},
hovered: { scale: 1.1 }
})
return { blockRef }
}
}
11. Future of Vue.js
Evolution of the Framework
Vue continues to evolve with a focus on performance, developer experience, and integration with the modern ecosystem. The development team is committed to maintaining stability while introducing new features.
Future versions aim to:
- Further improve compiler and runtime performance
- Expand low-level APIs for library developers
- Improve integration with modern development tools
Vue 3.4 and Beyond
Vue 3.4 introduced compiler improvements with significant performance optimizations and improved TypeScript support. Future versions will likely include:
- Improvements to the reactivity system
- Better integration with Vite and modern tools
- Expansion of Composition API capabilities
- Improved support for Suspense and async features
Beyond technical features, the Vue community is growing with more companies adopting the framework for enterprise projects. This will likely lead to an even richer ecosystem of tools, libraries, and best practices.
Conclusion
Vue.js is a mature yet continuously evolving framework that manages to balance power and simplicity. Its progressive architecture makes it suitable for both small projects and complex enterprise applications.
With the introduction of Vue 3 and the Composition API, along with modern tools like Vite and Pinia, developing with Vue has become even more efficient and enjoyable. Whether you’re just starting out or migrating from Vue 2, the framework offers a clear and well-documented path.
If you haven’t tried Vue.js yet, now is the perfect time to do so!