Vue 3 Complete Guide: Composition API, Pinia, Vue Router 4, and TypeScript (2024/2025)
A comprehensive Vue 3 guide covering the Composition API, script setup syntax, Vue Router 4, Pinia state management, composables, template directives, performance optimization, and Vue 3 vs Vue 2 vs React vs Angular comparison with real TypeScript code examples.
TL;DR
Vue 3 is a progressive JavaScript framework that excels at being incrementally adoptable. Its Composition API (replacing the Options API) enables better TypeScript integration, logic reuse via composables, and fine-grained reactivity. Vue 3 is 41% smaller than Vue 2, supports tree-shaking, and includes powerful new features like Teleport, Suspense, and defineAsyncComponent. The script setup syntax dramatically reduces boilerplate. Pinia has replaced Vuex as the official state management solution. If you are building a new project, use Vue 3 with script setup, TypeScript, Pinia, and Vue Router 4.
Key Takeaways
- The Composition API is the recommended way to write Vue 3 components — it groups logic by feature rather than by lifecycle hook, making large components far easier to maintain and test.
- script setup is syntactic sugar over the Composition API that eliminates boilerplate: no need to return values, defineProps/defineEmits replace the options object, and TypeScript inference works automatically.
- Pinia is the official Vue state management library — it uses the Composition API, is fully TypeScript-typed, supports Vue DevTools, and is simpler than Vuex with no mutations.
- Composables (useXxx functions) are the Vue 3 pattern for reusable stateful logic — they replace mixins and provide/inject for most use cases and are fully typed with TypeScript.
- Vue 3 is 41% smaller than Vue 2 with full tree-shaking support, meaning unused features are not included in your production bundle.
- Vue Router 4 is designed for Vue 3 with first-class TypeScript support, composition API integration (useRoute, useRouter), and improved navigation guards.
Vue 3, released in September 2020, is a complete redesign of Vue 2 focused on performance, TypeScript support, and developer experience. Created by Evan You and maintained by an active open-source community, Vue 3 powers applications at companies like Alibaba, Xiaomi, Gitlab, and thousands of other organizations worldwide. The framework strikes a unique balance: it is approachable for beginners (the template syntax is close to plain HTML), powerful for advanced use cases (the Composition API rivals React hooks in expressiveness), and progressive by design (you can adopt it incrementally in any project). This guide is your complete reference for Vue 3 in 2024/2025, covering everything from the basics of ref and reactive to advanced patterns like custom composables, Pinia stores, and performance optimization.
What is Vue 3? The Progressive Framework
Vue 3 describes itself as "the progressive JavaScript framework." This means it is designed to be adoptable at different scales: use it as a standalone script to add interactivity to a static HTML page, or use it as a full-featured SPA framework with routing, state management, and server-side rendering. Unlike Angular (which is opinionated about every layer of your stack) or React (which is just a rendering library requiring third-party decisions for everything), Vue occupies a sweet middle ground — it provides sensible defaults and official solutions for routing (Vue Router) and state (Pinia) while remaining flexible.
Vue 3 Core Improvements Over Vue 2
Vue 3 rewrites the entire framework in TypeScript and introduces a new virtual DOM implementation (inspired by Inferno) that is significantly faster. Key improvements include: 41% smaller bundle size, up to 55% faster rendering, up to 133% faster component initialization, Proxy-based reactivity (replacing Object.defineProperty — no more Vue.set() hacks), the Composition API for better logic organization, Fragments (multiple root elements in templates), Teleport for portal-like rendering, and Suspense for async component handling.
The Vue 3 Ecosystem
The official Vue 3 ecosystem includes: Vue Router 4 (client-side routing), Pinia (state management, successor to Vuex), Vite (build tool, created by Evan You), Vitest (unit testing), Vue DevTools (browser extension for debugging), VueUse (collection of 200+ composable utilities), Nuxt 3 (full-stack framework built on Vue 3, similar to Next.js for React), and Quasar/Vuetify/PrimeVue for UI component libraries.
Composition API Basics: ref, reactive, computed, watch
The Composition API is a set of functions that allows you to organize component logic by feature rather than by lifecycle hook. In the Options API, logic for a single feature (e.g., a search function) is split across data, methods, computed, and watch options. The Composition API puts all related logic together inside the setup() function, making components easier to understand, test, and extract into reusable composables.
ref() and reactive(): Reactive State
ref() creates a reactive reference to a primitive value (string, number, boolean). Access and mutate the value via .value in JavaScript. In templates, Vue automatically unwraps ref so you do not need .value. reactive() creates a reactive object — it is Proxy-based and deeply reactive. Use ref() for primitive values and when you need to reassign the whole value; use reactive() for complex objects where you always access properties. A key rule: never destructure a reactive() object without using toRefs() or storeToRefs(), as destructuring breaks reactivity.
// ref() for primitives
import { ref, reactive, computed, watch, watchEffect } from 'vue';
const count = ref(0);
const message = ref('Hello Vue 3');
// Access via .value in <script>
count.value++; // 1
console.log(count.value); // 1
// In template, Vue auto-unwraps: {{ count }} not {{ count.value }}
// reactive() for objects
const user = reactive({
name: 'Alice',
age: 30,
address: { city: 'Paris' },
});
// Mutate directly (no .value needed)
user.name = 'Bob';
user.address.city = 'London'; // deep reactivity works
// WRONG: breaking reactivity by destructuring
// const { name } = user; // name is no longer reactive!
// CORRECT: use toRefs to maintain reactivity
import { toRefs } from 'vue';
const { name, age } = toRefs(user); // still reactivecomputed(), watch(), and watchEffect()
computed() creates a memoized derived value — it automatically tracks dependencies and only recomputes when they change. Use it for any value derived from reactive state. watch() explicitly watches one or more reactive sources and runs a callback when they change — it is lazy by default (does not run on initial render) and receives both the new and old values. watchEffect() immediately runs its callback and automatically tracks any reactive dependencies accessed inside it — it is ideal for side effects that should synchronize with state. Use { immediate: true } on watch() to replicate watchEffect() behavior.
import { ref, computed, watch, watchEffect } from 'vue';
const firstName = ref('Evan');
const lastName = ref('You');
// computed: auto-tracks dependencies, memoized
const fullName = computed(() => firstName.value + ' ' + lastName.value);
console.log(fullName.value); // "Evan You"
// Writable computed
const reversedName = computed({
get: () => firstName.value.split('').reverse().join(''),
set: (val: string) => { firstName.value = val.split('').reverse().join(''); },
});
// watch: explicit deps, receives old + new value
watch(firstName, (newVal, oldVal) => {
console.log('firstName changed from ' + oldVal + ' to ' + newVal);
});
// watch multiple sources
watch([firstName, lastName], ([newFirst, newLast], [oldFirst, oldLast]) => {
console.log('Name changed');
});
// watch with immediate: true
watch(firstName, (val) => { document.title = val; }, { immediate: true });
// watchEffect: auto-tracks, runs immediately
watchEffect(() => {
console.log('firstName is now:', firstName.value); // runs on mount + changes
console.log('lastName is now:', lastName.value);
});<script setup> Syntax: Eliminating Boilerplate
The <script setup> syntax (introduced in Vue 3.2) is compiled syntactic sugar for the Composition API inside a Single File Component. It is the recommended way to write Vue 3 components because it is more concise, has better TypeScript inference, and performs better at runtime. Variables, functions, and imports declared at the top level are automatically available in the template — no explicit return statement needed.
defineProps, defineEmits, and defineExpose
Inside <script setup>, props are declared with defineProps() and emits with defineEmits() — both are compiler macros that are available without importing. With TypeScript, you can define props using the generic type syntax: defineProps<{ title: string; count: number }>() — this provides full type safety and IDE autocompletion. defineEmits<{ change: [value: string]; submit: [] }>() similarly types your emitted events. defineExpose() explicitly exposes component internals to the parent via template refs (since script setup components are closed by default).
<!-- UserCard.vue -->
<script setup lang="ts">
import { ref, computed } from 'vue';
// Props with TypeScript generics — no import needed for defineProps
const props = defineProps<{
name: string;
age: number;
role?: string; // optional prop
}>();
// Default values with withDefaults
const propsWithDefaults = withDefaults(defineProps<{ role?: string }>(), {
role: 'viewer',
});
// Emits with TypeScript
const emit = defineEmits<{
select: [userId: number]; // named tuple syntax
update: [name: string, age: number];
close: []; // no payload
}>();
const isExpanded = ref(false);
const initials = computed(() =>
props.name.split(' ').map(n => n[0]).join('')
);
function handleSelect() {
emit('select', 42);
}
// defineExpose: make internal state accessible via template ref
defineExpose({ isExpanded, initials });
</script>
<template>
<div @click="handleSelect">
<span>{{ initials }}</span>
<p>{{ props.name }}, {{ props.age }}</p>
</div>
</template>useTemplateRef() and Template Refs
useTemplateRef() (Vue 3.5+) is the new way to get a reference to a DOM element or child component instance. Use it with the ref attribute in templates: const el = useTemplateRef("myEl"). Before Vue 3.5, the pattern was const el = ref<HTMLElement | null>(null) with the matching ref="el" in the template. Template refs are populated after the component mounts, so always use them inside onMounted() or watch them for null checks.
<script setup lang="ts">
import { useTemplateRef, onMounted, ref } from 'vue';
// Vue 3.5+ approach
const inputEl = useTemplateRef<HTMLInputElement>('myInput');
// Pre-3.5 approach (still works)
const legacyInputEl = ref<HTMLInputElement | null>(null);
onMounted(() => {
// Populated after mount
inputEl.value?.focus();
legacyInputEl.value?.select();
});
</script>
<template>
<!-- ref attribute matches the string in useTemplateRef() -->
<input ref="myInput" type="text" placeholder="Auto-focused on mount" />
<input ref="legacyInputEl" type="text" placeholder="Legacy approach" />
</template>Vue Router 4: Navigation, Guards, and Dynamic Routes
Vue Router 4 is the official router for Vue 3, rebuilt with TypeScript and the Composition API in mind. It provides declarative, component-based routing for single-page applications. createRouter() replaces new VueRouter(), and createWebHistory() replaces mode: "history". The Composition API integration via useRoute() and useRouter() replaces the this.$route / this.$router options API accessors.
Setting Up Vue Router 4
Configure Vue Router 4 by defining route records, creating the router with createRouter(), and installing it with app.use(router). Each route record maps a path to a component. The history mode (createWebHistory) uses the HTML5 History API for clean URLs without hash. The hash mode (createWebHashHistory) uses the URL hash for environments without server-side URL rewriting support. The memory mode (createMemoryHistory) is used for SSR and testing.
// src/router/index.ts
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router';
const routes: RouteRecordRaw[] = [
{
path: '/',
name: 'home',
component: () => import('../views/HomeView.vue'), // lazy-loaded
},
{
path: '/users',
component: () => import('../views/UsersLayout.vue'),
children: [
{ path: '', name: 'user-list', component: () => import('../views/UserList.vue') },
{ path: ':id', name: 'user-detail', component: () => import('../views/UserDetail.vue') },
],
},
{
path: '/admin',
component: () => import('../views/AdminView.vue'),
meta: { requiresAuth: true, requiresAdmin: true },
},
{ path: '/:pathMatch(.*)*', name: 'not-found', component: () => import('../views/NotFound.vue') },
];
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes,
scrollBehavior(to, from, savedPosition) {
if (savedPosition) return savedPosition;
return { top: 0 };
},
});
export default router;Navigation Guards and Route Meta
Navigation guards run before navigation is confirmed, allowing you to redirect or cancel. Global guards (router.beforeEach) run on every navigation. Per-route guards (beforeEnter on route records) run only for specific routes. Component guards (onBeforeRouteLeave, onBeforeRouteUpdate from the Composition API) run for component navigation events. Route meta fields (meta: { requiresAuth: true }) store arbitrary data about a route, commonly used to protect routes with authentication checks in global guards.
// Global navigation guard — authentication check
router.beforeEach(async (to, from) => {
const authStore = useAuthStore();
if (to.meta.requiresAuth && !authStore.isLoggedIn) {
return { name: 'login', query: { redirect: to.fullPath } };
}
if (to.meta.requiresAdmin && !authStore.isAdmin) {
return { name: 'forbidden' };
}
// return undefined or true to proceed
});
// In-component guards (Composition API)
<script setup lang="ts">
import { onBeforeRouteLeave, onBeforeRouteUpdate, useRoute, useRouter } from 'vue-router';
const route = useRoute(); // reactive current route
const router = useRouter(); // router instance for programmatic navigation
console.log(route.params.id); // current :id param
console.log(route.query.page); // ?page= query param
console.log(route.meta); // typed route meta
onBeforeRouteLeave((to, from) => {
if (hasUnsavedChanges.value) {
return confirm('You have unsaved changes. Leave?');
}
});
onBeforeRouteUpdate((to, from) => {
// fires when same component used for different params
fetchUser(to.params.id as string);
});
// Programmatic navigation
router.push({ name: 'user-detail', params: { id: 42 } });
router.replace('/dashboard');
router.go(-1); // go back
</script>Dynamic Routes, Params, and Nested Routes
Dynamic route segments use the colon syntax (/users/:id). Access the current param via useRoute().params.id in script setup. Catch-all routes use (:path)* or :pathMatch(.*)*. Nested routes are defined by adding a children array to a route record — the parent route renders a <RouterView> for the child. Named routes (name: "user-detail") with router.push({ name: "user-detail", params: { id: 1 } }) are more maintainable than hardcoded path strings as your route structure evolves.
Pinia State Management: The Vuex Successor
Pinia is the official state management library for Vue 3, recommended by the Vue core team to replace Vuex 4. It is designed around the Composition API, has no mutations (only actions and state), is fully TypeScript-typed out of the box, supports Vue DevTools for time-travel debugging, is modular by design (no single large store file), and has a tiny footprint (~1.5KB gzipped). Pinia stores can be used in components, other stores, and outside components.
defineStore: Creating a Pinia Store
Use defineStore() to create a store. The first argument is a unique string ID used by DevTools. The second argument is either an Options object (with state, getters, and actions — similar to Vuex) or a Setup function (using the Composition API with ref, computed, and functions — the preferred approach). Both approaches produce stores that integrate fully with Vue DevTools, support plugins, and can be used with storeToRefs() for reactive destructuring.
// src/stores/counter.ts — Setup Store (preferred)
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
export const useCounterStore = defineStore('counter', () => {
// state
const count = ref(0);
const history = ref<number[]>([]);
// getters (computed)
const doubleCount = computed(() => count.value * 2);
const isPositive = computed(() => count.value > 0);
// actions
function increment() {
history.value.push(count.value);
count.value++;
}
async function fetchAndSet(id: number) {
const data = await fetch('/api/counts/' + id).then(r => r.json());
count.value = data.value;
}
function $reset() { count.value = 0; history.value = []; }
return { count, history, doubleCount, isPositive, increment, fetchAndSet, $reset };
});
// src/stores/user.ts — Options Store style
export const useUserStore = defineStore('user', {
state: () => ({ name: '', email: '', isLoggedIn: false }),
getters: {
displayName: (state) => state.name || 'Guest',
},
actions: {
async login(email: string, password: string) {
const user = await authService.login(email, password);
this.name = user.name;
this.email = user.email;
this.isLoggedIn = true;
},
logout() {
this.$patch({ name: '', email: '', isLoggedIn: false });
},
},
});storeToRefs, Actions, and DevTools
To destructure reactive properties from a Pinia store while maintaining reactivity, use storeToRefs() — this wraps each state property and getter in a ref. Methods (actions) can be destructured directly without storeToRefs(). Pinia actions replace both Vuex mutations and actions — they can be synchronous or asynchronous, can access this (the store instance), and are automatically tracked in Vue DevTools timeline. Use $patch() for batch state updates without going through actions.
<script setup lang="ts">
import { storeToRefs } from 'pinia';
import { useCounterStore } from '@/stores/counter';
const counterStore = useCounterStore();
// storeToRefs preserves reactivity for state + getters
const { count, doubleCount, isPositive } = storeToRefs(counterStore);
// Actions can be destructured directly (they are not reactive)
const { increment, fetchAndSet } = counterStore;
// Batch update with $patch
counterStore.$patch({ count: 10 });
// $patch with mutation function for complex updates
counterStore.$patch((state) => {
state.count += 5;
state.history.push(state.count);
});
// Subscribe to store changes
counterStore.$subscribe((mutation, state) => {
console.log('Store mutated:', mutation.type, state.count);
localStorage.setItem('counter', JSON.stringify(state));
});
</script>
<template>
<p>Count: {{ count }} | Double: {{ doubleCount }}</p>
<button @click="increment">Increment</button>
</template>Vue 3 Template Directives
Vue directives are special HTML attributes prefixed with v- that apply reactive behavior to the DOM. They are the bridge between your reactive data and the HTML template. Vue 3 provides built-in directives for conditional rendering, list rendering, two-way binding, event handling, and attribute binding. Custom directives allow you to encapsulate direct DOM manipulations into reusable, declarative HTML attributes.
v-if, v-else, v-show, v-for
v-if / v-else / v-else-if conditionally renders elements — the element is destroyed and recreated in the DOM when the condition changes (best for elements that toggle infrequently). v-show toggles CSS display:none — the element stays in the DOM (best for elements that toggle frequently). v-for renders a list with item in items or (item, index) in items syntax. Always provide a :key attribute to v-for for efficient DOM diffing — use unique IDs from your data, not array indexes, to avoid rendering bugs when items are reordered or removed.
<template>
<!-- v-if / v-else-if / v-else: element destroyed+recreated -->
<div v-if="role === 'admin'">Admin Panel</div>
<div v-else-if="role === 'editor'">Editor Panel</div>
<div v-else>Viewer Panel</div>
<!-- v-show: always rendered, toggles display:none -->
<div v-show="isExpanded">Expandable Content</div>
<!-- v-for with :key (use unique ID, not array index) -->
<ul>
<li v-for="item in items" :key="item.id">
{{ item.name }} — ${{ item.price }}
</li>
</ul>
<!-- v-for with index -->
<ol>
<li v-for="(item, index) in items" :key="item.id">
{{ index + 1 }}. {{ item.name }}
</li>
</ol>
<!-- v-for on a range -->
<span v-for="n in 5" :key="n">{{ n }} </span>
<!-- Use <template> to group without extra DOM elements -->
<template v-for="item in items" :key="item.id">
<dt>{{ item.term }}</dt>
<dd>{{ item.definition }}</dd>
</template>
</template>v-model, v-bind, v-on
v-model creates two-way data binding between a form input and reactive state. In Vue 3, v-model on a component expands to :modelValue="value" @update:modelValue="value = $event" — components should accept a modelValue prop and emit update:modelValue. Multiple v-model bindings are supported on a single component: v-model:title="title" v-model:content="content". v-bind (shorthand :) dynamically binds an attribute or prop. v-on (shorthand @) attaches event listeners. The .prevent, .stop, .once, .passive modifiers handle common event scenarios without boilerplate.
<script setup lang="ts">
import { ref } from 'vue';
const inputValue = ref('');
const isChecked = ref(false);
const selected = ref('option1');
</script>
<template>
<!-- Basic v-model on input -->
<input v-model="inputValue" type="text" />
<!-- v-model modifiers -->
<input v-model.trim="inputValue" /> <!-- trims whitespace -->
<input v-model.number="price" type="number" /> <!-- coerce to number -->
<input v-model.lazy="search" /> <!-- sync on change, not input -->
<!-- v-bind shorthand: -->
<img :src="user.avatar" :alt="user.name" />
<button :disabled="isLoading">{{ isLoading ? 'Loading...' : 'Submit' }}</button>
<!-- Bind object of attributes -->
<div v-bind="{ id: 'main', class: 'container', tabindex: 0 }">...</div>
<!-- v-on shorthand @ -->
<button @click="handleClick">Click</button>
<form @submit.prevent="handleSubmit">...</form>
<input @keyup.enter="search" @keyup.escape="clearSearch" />
<div @click.self="onOverlayClick">...</div> <!-- only fires on exact element -->
<!-- v-model on custom component (Vue 3) -->
<!-- Expands to: :modelValue="title" @update:modelValue="title = $event" -->
<MyInput v-model="title" />
<!-- Multiple v-model bindings -->
<UserForm v-model:name="user.name" v-model:email="user.email" />
</template>v-slot and Component Slots
Slots allow components to receive template content from their parents. The default slot renders any content placed between component tags. Named slots (<template #header>) allow multiple content areas. Scoped slots expose data from the child to the parent template — v-slot="slotProps" receives the data. Slots are the primary composition mechanism for layout components (cards, dialogs, tables) — they are more flexible and composable than prop-based rendering.
Composables and Reusable Logic
Composables are functions that use the Vue Composition API to encapsulate and reuse stateful logic. Named with a "use" prefix (useCounter, useFetch, useLocalStorage), they are the Vue 3 equivalent of React hooks and replace mixins entirely. Unlike mixins, composables have explicit data flow (you can see exactly what they return), do not pollute the component namespace, can accept and return reactive state, and can call each other for composition.
The useXxx Pattern and VueUse
A composable follows a simple pattern: it is a function that uses Vue reactivity APIs (ref, reactive, computed, watch, lifecycle hooks) and returns reactive state or methods. Composables should not have external side effects when initialized — they should be pure from the component's perspective. The VueUse library (vueuse.org) provides 200+ ready-made composables for common browser APIs, sensors, animation, state, and utilities — useFetch, useLocalStorage, useIntersectionObserver, useDark, useWindowSize, and many more.
// src/composables/useFetch.ts
import { ref, watchEffect, type Ref } from 'vue';
interface UseFetchReturn<T> {
data: Ref<T | null>;
error: Ref<Error | null>;
isLoading: Ref<boolean>;
refetch: () => void;
}
export function useFetch<T>(url: Ref<string> | string): UseFetchReturn<T> {
const data = ref<T | null>(null);
const error = ref<Error | null>(null);
const isLoading = ref(false);
let controller: AbortController;
async function fetchData() {
controller?.abort(); // cancel previous request
controller = new AbortController();
isLoading.value = true;
error.value = null;
try {
const endpoint = typeof url === 'string' ? url : url.value;
const res = await fetch(endpoint, { signal: controller.signal });
if (!res.ok) throw new Error('HTTP ' + res.status);
data.value = await res.json() as T;
} catch (e) {
if ((e as Error).name !== 'AbortError') error.value = e as Error;
} finally {
isLoading.value = false;
}
}
watchEffect(() => { fetchData(); });
return { data, error, isLoading, refetch: fetchData };
}
// Usage in component
<script setup lang="ts">
import { ref } from 'vue';
import { useFetch } from '@/composables/useFetch';
interface User { id: number; name: string; email: string; }
const userId = ref(1);
const url = computed(() => '/api/users/' + userId.value);
const { data: user, isLoading, error } = useFetch<User>(url);
</script>provide() and inject() for Deep Prop Passing
provide() and inject() solve prop drilling — passing props through many component layers just to reach a deeply nested component. A parent calls provide("key", value) to make a value available to all descendants. Any descendant calls inject("key") to receive it. In Vue 3 with TypeScript, use typed injection keys created with InjectionKey<T> from Vue for full type safety. Pinia stores eliminate most need for provide/inject for shared state, but it remains useful for component library APIs (e.g., a form component providing validation context to its inputs).
Vue 3 Performance: Teleport, Suspense, and Async Components
Vue 3 includes several built-in performance features beyond the faster virtual DOM. The compiler performs static analysis and hoists static virtual nodes, skipping them entirely during re-renders. It also uses patch flags to mark dynamic bindings, allowing the runtime to skip diffing static parts. These compiler optimizations happen automatically — you just write normal Vue templates.
Teleport: Rendering Outside the Component Tree
The <Teleport> component renders its content at a different location in the DOM than where it is declared in the component tree. Use it to render modals, toasts, and tooltips at the document.body level (avoiding z-index and overflow issues caused by ancestor CSS) while keeping the modal logic co-located with the component that controls it. The to prop accepts a CSS selector or DOM element. Multiple Teleports can target the same destination — they append in order.
<!-- Modal component using Teleport -->
<script setup lang="ts">
defineProps<{ isOpen: boolean; title: string }>();
defineEmits<{ close: [] }>();
</script>
<template>
<!-- Renders at document.body, not in component tree -->
<Teleport to="body">
<div v-if="isOpen" class="modal-overlay" @click.self="$emit('close')">
<div class="modal">
<h2>{{ title }}</h2>
<slot />
<button @click="$emit('close')">Close</button>
</div>
</div>
</Teleport>
</template>
<!-- Suspense + defineAsyncComponent -->
<script setup lang="ts">
import { defineAsyncComponent } from 'vue';
const HeavyChart = defineAsyncComponent({
loader: () => import('./HeavyChart.vue'),
loadingComponent: LoadingSpinner,
errorComponent: ErrorDisplay,
delay: 200, // show loading after 200ms
timeout: 10000, // error after 10s
});
</script>
<template>
<!-- Suspense with fallback slot -->
<Suspense>
<template #default>
<HeavyChart :data="chartData" />
</template>
<template #fallback>
<div>Loading chart...</div>
</template>
</Suspense>
</template>Suspense and Async Components
The <Suspense> component handles async dependencies in the component tree, displaying a fallback slot while the async operations complete. defineAsyncComponent() lazy-loads components on demand, splitting them into separate bundles. When used with <Suspense>, the parent can show a single loading state while multiple async components load simultaneously. This pattern eliminates scattered v-if="loading" checks and provides a clean loading/error boundary model (similar to React Suspense/Error Boundaries).
Vue 3 Tree-Shaking and Bundle Optimization
Vue 3 is fully tree-shakable — built-in APIs (Transition, KeepAlive, Teleport) are imported as needed rather than always included. If your application does not use Teleport, it is not in your bundle. This is why Vue 3's baseline bundle is 41% smaller than Vue 2. With Vite (the recommended build tool), development builds use native ESM for instant hot module replacement, and production builds use Rollup for aggressive tree-shaking and code splitting. Use defineAsyncComponent() and route-level code splitting to further reduce initial bundle size.
Vue 3 vs Vue 2 vs React vs Angular Comparison
Choosing the right framework depends on team expertise, project scale, and ecosystem requirements. Here is a comprehensive comparison across critical dimensions for Vue 3, Vue 2, React, and Angular.
| Dimension | Vue 3 | Vue 2 | React 18 | Angular 17 |
|---|---|---|---|---|
| Type | Progressive Framework | Progressive Framework | UI Library | Full Framework |
| TypeScript | Excellent (built-in) | Fair (needs config) | Good (with JSX) | Native, enforced |
| Learning Curve | Low–Medium | Low | Medium | High |
| State Management | Pinia (official) | Vuex (official) | Redux / Zustand / Jotai | NgRx / Signals |
| Routing | Vue Router 4 (official) | Vue Router 3 (official) | React Router / TanStack | @angular/router (built-in) |
| Full-Stack Framework | Nuxt 3 | Nuxt 2 | Next.js | Angular Universal |
| Bundle Size (baseline) | ~22KB gzipped | ~33KB gzipped | ~42KB gzipped | ~75KB gzipped |
| Performance | Very Fast | Fast | Fast (Concurrent) | Good (Signals) |
| Template Syntax | HTML Templates + JSX | HTML Templates | JSX (required) | HTML Templates (Angular-specific) |
| Enterprise Adoption | Medium (Alibaba, Xiaomi) | Medium (legacy) | Very High (Meta, Netflix) | High (Google, enterprise) |
| Lifecycle Status | Actively maintained | EOL (Dec 2023) | Actively maintained | Actively maintained |
Frequently Asked Questions About Vue 3
Should I migrate from Vue 2 to Vue 3?
Yes — Vue 2 reached end of life on December 31, 2023, meaning no more security patches or bug fixes. Migration difficulty depends on your codebase size. Small-to-medium projects can migrate directly using the @vue/compat migration build, which enables Vue 3 with Vue 2-compatible behavior and console warnings for deprecated APIs. Large projects should plan a phased migration: first update to Vue 2.7 (which backports the Composition API), then migrate to Vue 3. The key breaking changes are: the Options API still works in Vue 3, but filters are removed, $on/$off/$once are removed, and some directives have syntax changes.
What is the difference between the Composition API and the Options API?
The Options API organizes component logic into predefined option buckets: data, methods, computed, watch, mounted, etc. This is familiar and works well for simple components but can make large components hard to follow as related logic is scattered across multiple options. The Composition API uses setup() (or script setup) as a single entry point where you organize logic by feature rather than by option type. A "user search" feature's state, computed values, and fetch logic can all live together in one place. The Composition API also enables better TypeScript inference and composables for logic reuse. Both APIs are fully supported in Vue 3 — you can even mix them in the same component.
Is Vue 3 good with TypeScript?
Vue 3 has excellent TypeScript support. The entire Vue 3 framework is written in TypeScript. The script setup syntax with defineProps<Props>() provides full generic type inference for props. The Composition API provides natural TypeScript integration — ref<string>(), computed<number>(), and typed reactive objects all work with full IDE autocompletion. Pinia is completely TypeScript-native. Volar (the official VSCode extension) provides template type-checking and autocompletion that rivals Angular's type system. For new projects, Vue 3 + TypeScript + Volar provides a first-class typed development experience.
What is Nuxt 3 and when should I use it?
Nuxt 3 is the full-stack Vue framework, analogous to Next.js for React. It is built on Vue 3, Vite, and Nitro (a universal server engine). Nuxt 3 provides server-side rendering (SSR) for improved SEO and initial load performance, static site generation (SSG), file-based routing, auto-imports for Vue, Pinia, and VueUse composables, a server API layer (similar to Next.js API routes), and isomorphic data fetching (useFetch, useAsyncData). Use Nuxt 3 when SEO matters (marketing sites, e-commerce, blogs), when you need a backend API co-located with your frontend, or when you want a batteries-included Vue 3 experience.
How does Vue 3 reactivity work under the hood?
Vue 3 reactivity is based on ES6 Proxy. When you create a reactive() object, Vue wraps it in a Proxy with get/set traps. When a computed property or watch effect accesses a reactive property (get trap), Vue tracks it as a dependency. When you update the property (set trap), Vue notifies all tracked dependents. ref() works similarly — it wraps the value in an object with a .value getter/setter. This Proxy-based approach eliminates Vue 2's limitations: deep reactivity without explicit Vue.set(), array index mutations work, and new properties added to objects are reactive. Computed values are memoized lazy: they only compute when a dependency changes AND the computed is accessed.
What replaced Vuex in Vue 3?
Pinia is the official replacement for Vuex in Vue 3, recommended by the Vue core team. It was created by Eduardo San Martin Morote (a Vue core team member) and is designed around the Vue 3 Composition API. The key differences from Vuex are: no mutations (only state, getters, and actions), full TypeScript support without complex type workarounds, no nested modules (just separate stores that can import each other), lighter API, and native Vue DevTools integration. Vuex 4 still works with Vue 3 but is in maintenance mode — all new projects should use Pinia.
Can I use Vue 3 without a build step?
Yes — Vue 3 can be used as a plain CDN script for progressive enhancement of existing HTML pages, no build tools required. The global build (vue.global.js) exposes all APIs on the Vue global. The ESM browser build (vue.esm-browser.js) can be used directly in native ES modules. For complex applications with Single File Components (.vue files), you need a build step — Vite is the recommended tool and provides near-instant development server startup, sub-second hot module replacement, and optimized production builds. Vue Playground (play.vuejs.org) lets you try Vue 3 online without any setup.
What is the difference between v-if and v-show?
v-if fully renders or destroys the element and its children when the condition changes. When false, the element does not exist in the DOM — it runs lifecycle hooks (onMounted/onUnmounted), handles child components being created/destroyed, and has higher toggle cost. v-show always renders the element but toggles CSS display:none. It has lower toggle cost but higher initial render cost (always renders). Use v-if when the condition rarely changes or when you want to lazy-render heavy components. Use v-show for frequently toggled content (tabs, accordion) where you want to avoid re-rendering cost.