Vue 3: The Complete Guide for 2026

Published February 12, 2026 · 35 min read

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

Vue 3 Ecosystem

Library Purpose
PiniaOfficial state management (replaces Vuex)
Vue Router 4Official client-side routing
VueUseCollection of essential composables
Nuxt 3Full-stack framework with SSR/SSG
VitestVite-native unit testing framework
Vue DevToolsBrowser 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 }} &mdash; {{ 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 }} &middot; {{ 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

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

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

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.

Related Resources

Next.js Complete Guide
Compare Vue with the leading React framework
TypeScript Complete Guide
Strong typing for your Vue 3 components
React Hooks Complete Guide
Compare Vue composables with React hooks
JavaScript ES6 Features Guide
Modern JS foundations powering Vue 3
JavaScript Playground
Test Vue logic and utility functions live
HTML to JSX Converter
Convert between Vue templates and JSX