Vue 3: The Complete Guide for 2026
Vue 3 is a progressive JavaScript framework for building user interfaces. With the Composition API, improved performance through a proxy-based reactivity system, and first-class TypeScript support, Vue 3 is a powerful choice for applications of any size. Whether you are building a single-page application, a complex dashboard, or adding interactivity to a server-rendered page, Vue 3 provides an approachable yet scalable architecture.
This guide covers everything from project setup through production-ready patterns, with practical code examples you can use immediately.
1. What Is Vue 3
Vue 3 is a complete rewrite of Vue 2, released in September 2020 and now the default version. It introduced the Composition API, a faster virtual DOM, better tree-shaking, improved TypeScript support, and multiple root elements in templates (fragments).
Key Improvements Over Vue 2
- Composition API: Organize logic by feature instead of by option type. Share and reuse stateful logic through composables.
- Proxy-based reactivity: Uses JavaScript Proxy instead of Object.defineProperty, enabling detection of property additions, deletions, and array index changes.
- Performance: Up to 2x faster mounting, 1.3-2x faster updates, and up to 50% less memory usage compared to Vue 2.
- Tree-shaking: Unused framework features are excluded from the production bundle. A minimal Vue 3 app can be under 10KB gzipped.
- TypeScript: Written in TypeScript with full type inference for the Composition API.
- Fragments: Components can have multiple root elements without a wrapper div.
- Teleport: Render content in a different DOM location (useful for modals and tooltips).
- Suspense: Built-in support for async dependencies with fallback content.
Vue 3 Ecosystem
| Library | Purpose |
|---|---|
| Pinia | Official state management (replaces Vuex) |
| Vue Router 4 | Official client-side routing |
| VueUse | Collection of essential composables |
| Nuxt 3 | Full-stack framework with SSR/SSG |
| Vitest | Vite-native unit testing framework |
| Vue DevTools | Browser extension for debugging |
2. Getting Started
The fastest way to scaffold a Vue 3 project is with create-vue, the official project scaffolding tool built on Vite:
npm create vue@latest my-app
# You will be prompted to select options:
# TypeScript? Yes
# JSX Support? No
# Vue Router? Yes
# Pinia? Yes
# Vitest? Yes
# ESLint + Prettier? Yes
cd my-app
npm install
npm run dev
Project Structure
my-app/
src/
assets/ # Static assets (CSS, images)
components/ # Reusable Vue components
composables/ # Composition API composables
router/ # Vue Router configuration
stores/ # Pinia stores
views/ # Route-level page components
App.vue # Root component
main.ts # Application entry point
public/ # Static files served at root
index.html # HTML entry point
vite.config.ts # Vite configuration
tsconfig.json # TypeScript configuration
Entry Point
// src/main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import router from './router'
import App from './App.vue'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')
3. Template Syntax
Vue uses an HTML-based template syntax that binds the rendered DOM to the component's data. Templates are compiled into optimized render functions at build time.
Text Interpolation and Directives
<template>
<!-- Text interpolation -->
<p>{{ message }}</p>
<p>{{ count * 2 }}</p>
<!-- Attribute binding with v-bind (shorthand :) -->
<img :src="imageUrl" :alt="imageAlt">
<div :class="{ active: isActive, 'text-bold': isBold }"></div>
<div :style="{ color: textColor, fontSize: size + 'px' }"></div>
<!-- Event handling with v-on (shorthand @) -->
<button @click="handleClick">Click me</button>
<button @click="count++">Increment</button>
<form @submit.prevent="onSubmit">...</form>
<input @keyup.enter="search">
<!-- Two-way binding -->
<input v-model="searchQuery" placeholder="Search...">
<textarea v-model="bio"></textarea>
<select v-model="selected">
<option value="a">A</option>
<option value="b">B</option>
</select>
</template>
Conditional and List Rendering
<template>
<!-- Conditional rendering -->
<div v-if="status === 'loading'">Loading...</div>
<div v-else-if="status === 'error'">Something went wrong</div>
<div v-else>{{ data }}</div>
<!-- v-show: toggles CSS display (keeps element in DOM) -->
<p v-show="isVisible">I am toggled with CSS</p>
<!-- List rendering -->
<ul>
<li v-for="item in items" :key="item.id">
{{ item.name }} — {{ item.price }}
</li>
</ul>
<!-- v-for with index -->
<div v-for="(user, index) in users" :key="user.id">
{{ index + 1 }}. {{ user.name }}
</div>
<!-- v-for over an object -->
<div v-for="(value, key) in myObject" :key="key">
{{ key }}: {{ value }}
</div>
</template>
4. Composition API
The Composition API is the modern way to write Vue components. The <script setup> syntax is the recommended approach — it is more concise and provides better type inference.
ref and reactive
<script setup>
import { ref, reactive } from 'vue'
// ref: wraps a value in a reactive reference
const count = ref(0)
const message = ref('Hello Vue 3')
// Access/modify with .value in script
console.log(count.value) // 0
count.value++
// reactive: makes an entire object reactive
const state = reactive({
users: [],
loading: false,
error: null
})
// No .value needed for reactive objects
state.loading = true
state.users.push({ id: 1, name: 'Alice' })
</script>
<template>
<!-- No .value needed in templates -->
<p>{{ count }}</p>
<p>{{ message }}</p>
<p v-if="state.loading">Loading...</p>
</template>
computed and watch
<script setup>
import { ref, computed, watch, watchEffect } from 'vue'
const firstName = ref('John')
const lastName = ref('Doe')
// Computed: derived reactive state (cached)
const fullName = computed(() => {
return `${firstName.value} ${lastName.value}`
})
// Writable computed
const fullNameWritable = computed({
get: () => `${firstName.value} ${lastName.value}`,
set: (val) => {
const [first, ...rest] = val.split(' ')
firstName.value = first
lastName.value = rest.join(' ')
}
})
// watch: react to specific source changes
watch(firstName, (newVal, oldVal) => {
console.log(`firstName changed: ${oldVal} -> ${newVal}`)
})
// Watch multiple sources
watch([firstName, lastName], ([newFirst, newLast]) => {
console.log(`Name: ${newFirst} ${newLast}`)
})
// Deep watch for reactive objects
const filters = ref({ search: '', category: 'all' })
watch(filters, (newFilters) => {
fetchResults(newFilters)
}, { deep: true })
// watchEffect: automatically tracks dependencies
watchEffect(() => {
console.log(`Current name: ${firstName.value} ${lastName.value}`)
// Re-runs whenever firstName or lastName changes
})
</script>
5. Components
Components are the building blocks of Vue applications. With <script setup>, props, emits, slots, and other component features use compiler macros.
Props and Emits
<!-- UserCard.vue -->
<script setup>
// Define props with defaults and validation
const props = defineProps({
name: { type: String, required: true },
email: { type: String, required: true },
role: { type: String, default: 'user' },
isActive: { type: Boolean, default: true }
})
// Define emitted events
const emit = defineEmits(['update', 'delete'])
function handleDelete() {
emit('delete', props.email)
}
</script>
<template>
<div class="user-card">
<h3>{{ name }}</h3>
<p>{{ email }} · {{ role }}</p>
<span v-if="isActive" class="badge">Active</span>
<button @click="$emit('update', email)">Edit</button>
<button @click="handleDelete">Delete</button>
</div>
</template>
<!-- Parent usage -->
<UserCard
name="Alice"
email="alice@example.com"
role="admin"
@update="onUpdate"
@delete="onDelete"
/>
Slots
<!-- Card.vue -->
<template>
<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>
</template>
<!-- Usage with named slots -->
<Card>
<template #header>
<h2>User Profile</h2>
</template>
<p>This goes into the default slot.</p>
<template #footer>
<button>Save Changes</button>
</template>
</Card>
Provide / Inject
<!-- Parent component -->
<script setup>
import { provide, ref } from 'vue'
const theme = ref('dark')
const toggleTheme = () => {
theme.value = theme.value === 'dark' ? 'light' : 'dark'
}
// Provide values to all descendants
provide('theme', theme)
provide('toggleTheme', toggleTheme)
</script>
<!-- Any descendant component -->
<script setup>
import { inject } from 'vue'
const theme = inject('theme')
const toggleTheme = inject('toggleTheme')
</script>
<template>
<div :class="theme">
Current theme: {{ theme }}
<button @click="toggleTheme">Toggle</button>
</div>
</template>
6. Lifecycle Hooks
Vue 3 lifecycle hooks in the Composition API are imported from vue and called inside <script setup>:
<script setup>
import {
onBeforeMount,
onMounted,
onBeforeUpdate,
onUpdated,
onBeforeUnmount,
onUnmounted,
onActivated,
onDeactivated,
onErrorCaptured
} from 'vue'
onBeforeMount(() => console.log('About to mount'))
onMounted(() => {
// DOM is available - fetch data, set up listeners
fetchData()
window.addEventListener('resize', handleResize)
})
onBeforeUpdate(() => console.log('About to re-render'))
onUpdated(() => console.log('Re-rendered'))
onBeforeUnmount(() => console.log('About to be destroyed'))
onUnmounted(() => {
// Clean up event listeners, timers, subscriptions
window.removeEventListener('resize', handleResize)
})
// For components inside <KeepAlive>
onActivated(() => console.log('Activated from cache'))
onDeactivated(() => console.log('Deactivated to cache'))
// Capture errors from descendants
onErrorCaptured((err, instance, info) => {
console.error('Error captured:', err, info)
return false // Prevent error from propagating
})
</script>
The mapping from Options API to Composition API: beforeCreate/created are replaced by setup() itself, beforeMount becomes onBeforeMount, mounted becomes onMounted, beforeUpdate/updated become onBeforeUpdate/onUpdated, and beforeUnmount/unmounted become onBeforeUnmount/onUnmounted.
7. State Management with Pinia
Pinia is the official state management solution for Vue 3. It replaces Vuex with a simpler, type-safe API that integrates naturally with the Composition API.
Defining a Store
// stores/useUserStore.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
// Composition API syntax (recommended)
export const useUserStore = defineStore('user', () => {
// State
const users = ref([])
const currentUser = ref(null)
const loading = ref(false)
// Getters (computed)
const activeUsers = computed(() =>
users.value.filter(u => u.isActive)
)
const userCount = computed(() => users.value.length)
// Actions (functions)
async function fetchUsers() {
loading.value = true
try {
const res = await fetch('/api/users')
users.value = await res.json()
} catch (err) {
console.error('Failed to fetch users:', err)
} finally {
loading.value = false
}
}
function setCurrentUser(user) {
currentUser.value = user
}
return { users, currentUser, loading, activeUsers, userCount,
fetchUsers, setCurrentUser }
})
Using a Store in Components
<script setup>
import { useUserStore } from '@/stores/useUserStore'
import { storeToRefs } from 'pinia'
const userStore = useUserStore()
// Use storeToRefs to keep reactivity when destructuring
const { users, loading, activeUsers } = storeToRefs(userStore)
// Actions can be destructured directly
const { fetchUsers, setCurrentUser } = userStore
// Fetch on mount
fetchUsers()
</script>
<template>
<div v-if="loading">Loading users...</div>
<ul v-else>
<li v-for="user in activeUsers" :key="user.id"
@click="setCurrentUser(user)">
{{ user.name }}
</li>
</ul>
</template>
8. Vue Router
Vue Router 4 is the official router for Vue 3. It provides declarative routing with dynamic segments, nested routes, navigation guards, and lazy loading.
Router Configuration
// router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{
path: '/',
name: 'Home',
component: () => import('@/views/HomeView.vue')
},
{
path: '/users',
name: 'Users',
component: () => import('@/views/UsersView.vue'),
children: [
{
path: ':id',
name: 'UserDetail',
component: () => import('@/views/UserDetailView.vue'),
props: true // Pass route params as props
}
]
},
{
path: '/dashboard',
name: 'Dashboard',
component: () => import('@/views/DashboardView.vue'),
meta: { requiresAuth: true }
},
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: () => import('@/views/NotFoundView.vue')
}
]
const router = createRouter({
history: createWebHistory(),
routes,
scrollBehavior(to, from, savedPosition) {
return savedPosition || { top: 0 }
}
})
export default router
Navigation Guards
// Global guard
router.beforeEach((to, from) => {
const userStore = useUserStore()
if (to.meta.requiresAuth && !userStore.currentUser) {
return { name: 'Login', query: { redirect: to.fullPath } }
}
})
// Per-route guard
{
path: '/admin',
component: AdminView,
beforeEnter: (to, from) => {
const userStore = useUserStore()
if (userStore.currentUser?.role !== 'admin') {
return { name: 'Home' }
}
}
}
Using the Router in Components
<script setup>
import { useRouter, useRoute } from 'vue-router'
const router = useRouter()
const route = useRoute()
// Read current route params
console.log(route.params.id)
console.log(route.query.search)
// Programmatic navigation
function goToUser(id) {
router.push({ name: 'UserDetail', params: { id } })
}
function goBack() {
router.back()
}
</script>
<template>
<!-- Declarative navigation -->
<RouterLink to="/">Home</RouterLink>
<RouterLink :to="{ name: 'Users' }">Users</RouterLink>
<!-- Route outlet -->
<RouterView />
</template>
9. Composables
Composables are functions that encapsulate and reuse stateful logic using the Composition API. They are the Vue 3 equivalent of React hooks and mixins, but with better type safety and explicit dependencies.
Creating a Composable
// composables/useFetch.ts
import { ref, watchEffect, type Ref } from 'vue'
export function useFetch<T>(url: string | Ref<string>) {
const data = ref<T | null>(null)
const error = ref<string | null>(null)
const loading = ref(false)
async function execute() {
loading.value = true
error.value = null
try {
const urlValue = typeof url === 'string' ? url : url.value
const res = await fetch(urlValue)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
data.value = await res.json()
} catch (err) {
error.value = err instanceof Error ? err.message : 'Unknown error'
} finally {
loading.value = false
}
}
// Auto-fetch and refetch when URL changes
watchEffect(() => { execute() })
return { data, error, loading, refetch: execute }
}
// Usage in a component
<script setup>
import { useFetch } from '@/composables/useFetch'
const { data: users, loading, error } = useFetch('/api/users')
</script>
More Composable Examples
// composables/useLocalStorage.ts
import { ref, watch } from 'vue'
export function useLocalStorage<T>(key: string, defaultValue: T) {
const stored = localStorage.getItem(key)
const data = ref<T>(stored ? JSON.parse(stored) : defaultValue)
watch(data, (v) => localStorage.setItem(key, JSON.stringify(v)), { deep: true })
return data
}
// composables/useDebounce.ts
import { ref, watch, type Ref } from 'vue'
export function useDebounce<T>(source: Ref<T>, delay = 300) {
const debounced = ref(source.value) as Ref<T>
let timeout: ReturnType<typeof setTimeout>
watch(source, (val) => {
clearTimeout(timeout)
timeout = setTimeout(() => { debounced.value = val }, delay)
})
return debounced
}
Composable Best Practices
- Naming: Always prefix with
use(e.g.,useFetch,useAuth,useTheme). - Return refs: Return
refvalues so consumers can destructure without losing reactivity. - Accept refs as input: Use
toValue()orunref()to handle both raw values and refs as parameters. - Clean up side effects: Use
onUnmountedto remove event listeners, cancel timers, and abort fetch requests. - Keep composables focused: One composable should handle one concern. Compose multiple composables together in the component.
10. TypeScript with Vue
Vue 3 has first-class TypeScript support. The Composition API was designed with TypeScript in mind, providing full type inference for props, emits, refs, and computed values.
Typed Props and Emits
<script setup lang="ts">
// Type-only props declaration (recommended)
interface Props {
title: string
count?: number
items: string[]
status: 'active' | 'inactive' | 'pending'
}
const props = withDefaults(defineProps<Props>(), {
count: 0,
status: 'active'
})
// Type-only emits declaration
interface Emits {
(e: 'update', id: number): void
(e: 'delete', id: number): void
(e: 'search', query: string): void
}
const emit = defineEmits<Emits>()
// TypeScript will enforce the correct event name and payload
emit('update', 42) // OK
// emit('update', 'abc') // Error: string not assignable to number
</script>
Typed Refs and Computed
<script setup lang="ts">
import { ref, computed } from 'vue'
interface User {
id: number
name: string
email: string
role: 'admin' | 'user'
}
// Type is inferred as Ref<User | null>
const currentUser = ref<User | null>(null)
// Type is inferred as Ref<User[]>
const users = ref<User[]>([])
// Computed type is inferred automatically
const admins = computed(() =>
users.value.filter(u => u.role === 'admin')
) // ComputedRef<User[]>
// Template ref typing
const inputRef = ref<HTMLInputElement | null>(null)
function focusInput() {
inputRef.value?.focus()
}
</script>
<template>
<input ref="inputRef" type="text">
</template>
11. Performance Optimization
Vue 3 is fast out of the box, but large applications benefit from intentional performance optimization. Here are the key techniques.
Async Components and Lazy Loading
import { defineAsyncComponent } from 'vue'
// Lazy-load a heavy component
const HeavyChart = defineAsyncComponent(() =>
import('./components/HeavyChart.vue')
)
// With loading and error states
const HeavyEditor = defineAsyncComponent({
loader: () => import('./components/HeavyEditor.vue'),
loadingComponent: LoadingSpinner,
errorComponent: ErrorDisplay,
delay: 200, // Show loading after 200ms
timeout: 10000 // Timeout after 10 seconds
})
KeepAlive for Caching
<template>
<!-- Cache components when switching tabs -->
<KeepAlive :include="['UserList', 'Dashboard']" :max="5">
<component :is="activeTab" />
</KeepAlive>
</template>
Suspense for Async Dependencies
<template>
<Suspense>
<template #default>
<AsyncDashboard />
</template>
<template #fallback>
<div class="loading">Loading dashboard...</div>
</template>
</Suspense>
</template>
<!-- AsyncDashboard.vue -->
<script setup>
// Top-level await makes this an async component
const data = await fetch('/api/dashboard').then(r => r.json())
</script>
Performance Tips
- Use
v-oncefor content that never changes after initial render. - Use
v-memoto memoize parts of the template with specific dependency arrays. - Use
shallowRefandshallowReactivefor large objects where you only need top-level reactivity. - Add
:keytov-foritems to help Vue's diffing algorithm. - Use virtual scrolling (vue-virtual-scroller) for lists with hundreds or thousands of items.
- Split large components into smaller ones to limit the scope of re-renders.
- Use
markRaw()for large objects that should never be reactive (class instances, third-party library objects).
12. Testing Vue Components
Vue Test Utils is the official testing library for Vue components. Combined with Vitest, it provides fast, type-safe component testing.
Setup
npm install -D vitest @vue/test-utils jsdom
// vite.config.ts
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
test: {
environment: 'jsdom',
globals: true
}
})
Component Testing
// components/__tests__/Counter.test.ts
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import Counter from '../Counter.vue'
describe('Counter', () => {
it('renders initial count', () => {
const wrapper = mount(Counter, {
props: { initialCount: 5 }
})
expect(wrapper.text()).toContain('5')
})
it('increments count on button click', async () => {
const wrapper = mount(Counter)
await wrapper.find('button.increment').trigger('click')
expect(wrapper.text()).toContain('1')
})
it('emits "changed" event with new count', async () => {
const wrapper = mount(Counter)
await wrapper.find('button.increment').trigger('click')
expect(wrapper.emitted('changed')).toHaveLength(1)
expect(wrapper.emitted('changed')[0]).toEqual([1])
})
})
// Testing with Pinia
import { createTestingPinia } from '@pinia/testing'
it('displays users from store', () => {
const wrapper = mount(UserList, {
global: {
plugins: [
createTestingPinia({
initialState: {
user: { users: [{ id: 1, name: 'Alice' }] }
}
})
]
}
})
expect(wrapper.text()).toContain('Alice')
})
Testing Composables
// composables/__tests__/useCounter.test.ts
import { describe, it, expect } from 'vitest'
import { useCounter } from '../useCounter'
describe('useCounter', () => {
it('starts with initial value', () => {
const { count } = useCounter(10)
expect(count.value).toBe(10)
})
it('increments and decrements', () => {
const { count, increment, decrement } = useCounter(0)
increment()
expect(count.value).toBe(1)
decrement()
expect(count.value).toBe(0)
})
})
Frequently Asked Questions
What is the difference between the Options API and the Composition API in Vue 3?
The Options API organizes code by option type (data, methods, computed, watch) inside a component object, while the Composition API organizes code by logical concern using the setup() function or the <script setup> syntax. The Composition API provides better TypeScript support, easier code reuse through composables, and more flexible logic organization. Both APIs are fully supported in Vue 3, but the Composition API is the recommended approach for new projects.
Should I use ref or reactive in Vue 3?
Use ref() for primitive values (strings, numbers, booleans) and reactive() for objects. The key difference is that ref wraps the value in an object with a .value property, while reactive makes the entire object reactive. The Vue team recommends ref() as the default because it works consistently with all value types, can be destructured without losing reactivity, and makes it explicit when you are accessing reactive state via .value.
What is Pinia and why should I use it instead of Vuex?
Pinia is the official state management library for Vue 3, replacing Vuex. It offers a simpler API without mutations, full TypeScript support out of the box, Composition API integration, automatic code splitting with lazy-loaded stores, and Vue DevTools support. Unlike Vuex, Pinia does not require mutations to change state — you can modify state directly or use actions. It also supports multiple stores without nested modules, making state management more intuitive.
How do I handle routing in a Vue 3 application?
Vue Router 4 is the official router for Vue 3. You define routes as an array mapping URL paths to components, create a router instance with createRouter() and createWebHistory(), and install it in your app with app.use(router). Vue Router supports dynamic route params, nested routes, navigation guards (beforeEach, beforeRouteEnter), lazy-loaded components via dynamic imports, and programmatic navigation with useRouter() and useRoute() composables.
Can I use TypeScript with Vue 3?
Yes, Vue 3 has first-class TypeScript support. When using <script setup lang="ts">, you get full type inference for props (via defineProps with type-only syntax), emits (via defineEmits), template refs, and computed properties. The Composition API was designed with TypeScript in mind, providing much better type inference than the Options API. Pinia and Vue Router also offer excellent TypeScript support. Use vue-tsc for type-checking .vue files in your CI pipeline.
Related Resources
- Next.js Complete Guide — compare Vue's approach with the leading React framework
- TypeScript Complete Guide — level up your Vue 3 development with strong types
- React Hooks Complete Guide — see how Vue composables compare to React hooks
- JavaScript ES6 Features Guide — the modern JS foundations powering Vue 3
- JavaScript Playground — test your Vue logic and utility functions live
- HTML to JSX Converter — useful when converting between Vue templates and JSX
Keep learning: Vue 3's Composition API shares many ideas with React Hooks. Check our React Hooks Guide for comparison, our TypeScript Guide for type safety patterns, and try our JavaScript Playground to experiment with Vue logic.