Vue 3 is the current major version of Vue.js, with the Composition API as the recommended way to write components. This cheat sheet covers everything you need — from ref and reactive to Pinia stores, Vue Router, and production patterns.
Quick reference
The 25 patterns that cover 95% of everyday Vue 3 development.
| Pattern | What it does |
|---|---|
const n = ref(0) |
Reactive primitive value |
const obj = reactive({ x: 0 }) |
Reactive object |
n.value |
Access ref value in script |
{{ n }} |
Auto-unwrapped in template |
const double = computed(() => n.value * 2) |
Derived value |
watch(n, (newVal, oldVal) => {}) |
React to changes |
watchEffect(() => console.log(n.value)) |
Auto-track dependencies |
v-if="show" |
Conditional render (destroy/create) |
v-show="show" |
Toggle CSS display |
v-for="item in list" |
List render |
v-model="text" |
Two-way binding |
v-bind:class="{ active: isActive }" |
Dynamic class |
v-on:click="handler" / @click |
Event listener |
:prop="value" |
Pass prop to child |
emit('event', payload) |
Emit to parent |
defineProps<{ title: string }>() |
Typed props |
defineEmits<{ submit: [value: string] }>() |
Typed emits |
provide('key', value) |
Provide to descendants |
inject('key') |
Inject from ancestor |
<slot /> |
Default slot |
<slot name="header" /> |
Named slot |
<Teleport to="body"> |
Render outside component tree |
<Suspense> |
Async component boundaries |
<KeepAlive> |
Cache component state |
useRoute() / useRouter() |
Vue Router in Composition API |
Setting up a Vue 3 project
# Vite (recommended)
npm create vue@latest my-app
cd my-app
npm install
npm run dev
# Manual Vite + Vue
npm create vite@latest my-app -- --template vue-ts
Minimal 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')
Single File Component (SFC) structure
<script setup lang="ts">
// Composition API — runs once at component creation
import { ref, computed, onMounted } from 'vue'
const count = ref(0)
const double = computed(() => count.value * 2)
function increment() {
count.value++
}
onMounted(() => {
console.log('component mounted')
})
</script>
<template>
<button @click="increment">Count: {{ count }} (x2: {{ double }})</button>
</template>
<style scoped>
button { padding: 0.5rem 1rem; }
</style>
<script setup> is the recommended syntax — it's more concise than the Options API and compiles to optimal code.
ref vs reactive
| Feature | ref |
reactive |
|---|---|---|
| Wraps | Any value (primitives, objects) | Objects/arrays only |
| Access in script | .value required |
Direct property access |
| Access in template | Auto-unwrapped (no .value) |
Direct property access |
| Destructuring | Loses reactivity (use toRefs) |
Loses reactivity (use toRefs) |
| Reassign entire value | ref.value = newObj ✓ |
Cannot replace root object |
// ref — good for primitives and when you reassign
const name = ref('Alice')
const user = ref({ name: 'Alice', age: 30 })
name.value = 'Bob'
user.value = { name: 'Bob', age: 25 } // full reassign OK
// reactive — good for objects when you never reassign root
const state = reactive({ count: 0, name: 'Alice' })
state.count++
state.name = 'Bob'
// toRefs — destructure without losing reactivity
const { count, name: userName } = toRefs(state)
count.value++ // still reactive
Computed properties
import { ref, computed } from 'vue'
const price = ref(100)
const taxRate = ref(0.2)
// Read-only computed
const total = computed(() => price.value * (1 + taxRate.value))
// Writable computed (getter + setter)
const fullName = computed({
get() {
return `${firstName.value} ${lastName.value}`
},
set(value: string) {
const [first, ...rest] = value.split(' ')
firstName.value = first
lastName.value = rest.join(' ')
},
})
fullName.value = 'John Doe' // triggers setter
Computed values are lazy (only recalculate when dependencies change) and cached (same result for same deps).
watch and watchEffect
import { ref, watch, watchEffect } from 'vue'
const query = ref('')
const results = ref([])
// watch — explicit source, lazy by default
watch(query, async (newQuery, oldQuery) => {
results.value = await fetchResults(newQuery)
})
// watch with options
watch(query, handler, {
immediate: true, // run immediately on mount
deep: true, // deep watch objects/arrays
once: true, // fire only once
})
// watch multiple sources
watch([firstName, lastName], ([first, last]) => {
console.log(`Name changed to ${first} ${last}`)
})
// watchEffect — auto-tracks all reactive deps used inside
watchEffect(async () => {
// automatically re-runs when query.value changes
results.value = await fetchResults(query.value)
})
// Stop a watcher
const stop = watchEffect(() => { /* ... */ })
stop() // call returned fn to stop
Lifecycle hooks
import {
onBeforeMount,
onMounted,
onBeforeUpdate,
onUpdated,
onBeforeUnmount,
onUnmounted,
onErrorCaptured,
} from 'vue'
onMounted(() => {
// DOM is ready — fetch data, init third-party libs
})
onUnmounted(() => {
// Cleanup — clear timers, unsubscribe events
})
onErrorCaptured((err, instance, info) => {
// Handle errors from child components
return false // prevents propagation
})
| Hook | When it runs |
|---|---|
onBeforeMount |
Before DOM insertion |
onMounted |
After DOM insertion ✓ most used |
onBeforeUpdate |
Before reactive update |
onUpdated |
After DOM update |
onBeforeUnmount |
Before component destroy |
onUnmounted |
After component destroy ✓ cleanup here |
onErrorCaptured |
Error from child tree |
Props and emits
<script setup lang="ts">
// defineProps — typed with TypeScript generics
const props = defineProps<{
title: string
count?: number // optional
items: string[]
}>()
// with defaults
const props = withDefaults(defineProps<{
title: string
count?: number
}>(), {
count: 0,
})
// defineEmits
const emit = defineEmits<{
submit: [value: string] // named tuple syntax
'update:modelValue': [val: number] // for v-model
close: [] // no payload
}>()
function handleClick() {
emit('submit', 'hello')
}
// v-model support
const props = defineProps<{ modelValue: string }>()
const emit = defineEmits<{ 'update:modelValue': [val: string] }>()
// Parent uses: <MyInput v-model="text" />
// Which is: <MyInput :modelValue="text" @update:modelValue="text = $event" />
</script>
Directives
<!-- v-if / v-else-if / v-else — destroys/creates DOM -->
<div v-if="user.isAdmin">Admin panel</div>
<div v-else-if="user.isMod">Mod panel</div>
<div v-else>User panel</div>
<!-- v-show — toggles CSS display, keeps DOM alive -->
<div v-show="isVisible">Always in DOM</div>
<!-- v-for — always add :key -->
<li v-for="(item, index) in items" :key="item.id">
{{ index }}: {{ item.name }}
</li>
<!-- v-for over object -->
<li v-for="(value, key, index) in obj" :key="key">
{{ key }}: {{ value }}
</li>
<!-- v-model modifiers -->
<input v-model.trim="text" /> <!-- trim whitespace -->
<input v-model.number="age" /> <!-- cast to number -->
<input v-model.lazy="query" /> <!-- sync on change, not input -->
<!-- v-bind shorthand -->
<img :src="url" :alt="description" />
<div :class="{ active: isActive, 'text-lg': isLarge }" />
<div :class="[baseClass, conditionalClass]" />
<div :style="{ color: textColor, fontSize: size + 'px' }" />
<!-- v-on shorthand with modifiers -->
<button @click.prevent="submit">Submit</button>
<input @keyup.enter="search" />
<div @click.stop="handleClick">No bubble</div>
<form @submit.prevent="onSubmit">...</form>
<!-- v-once — render once, never re-render -->
<h1 v-once>{{ staticTitle }}</h1>
Slots
<!-- Parent -->
<template>
<Card>
<template #header>
<h2>Custom Header</h2>
</template>
<p>Default slot content</p>
<template #footer="{ closeModal }">
<button @click="closeModal">Close</button>
</template>
</Card>
</template>
<!-- Card.vue — child -->
<template>
<div class="card">
<header><slot name="header" /></header>
<main><slot /></main> <!-- default slot -->
<footer>
<slot name="footer" :closeModal="close" /> <!-- scoped slot -->
</footer>
</div>
</template>
provide / inject (dependency injection)
// Ancestor component
import { provide, ref } from 'vue'
const theme = ref('dark')
provide('theme', theme) // inject ref to keep reactivity
provide('setTheme', (t: string) => { theme.value = t })
// Descendant (any depth)
import { inject } from 'vue'
const theme = inject<Ref<string>>('theme')
const setTheme = inject<(t: string) => void>('setTheme')
// With default value
const theme = inject('theme', ref('light'))
Prefer provide/inject for cross-cutting concerns (theme, auth, i18n). For app-wide state, use Pinia.
Composables (custom hooks)
Composables are functions that use the Composition API. They should start with use.
// composables/useCounter.ts
import { ref, computed } from 'vue'
export function useCounter(initialValue = 0) {
const count = ref(initialValue)
const isNegative = computed(() => count.value < 0)
function increment() { count.value++ }
function decrement() { count.value-- }
function reset() { count.value = initialValue }
return { count, isNegative, increment, decrement, reset }
}
// In a component
import { useCounter } from '@/composables/useCounter'
const { count, increment } = useCounter(10)
// composables/useFetch.ts — async data fetching
import { ref, watchEffect } from 'vue'
export function useFetch<T>(url: Ref<string>) {
const data = ref<T | null>(null)
const error = ref<Error | null>(null)
const loading = ref(false)
watchEffect(async () => {
loading.value = true
error.value = null
try {
const res = await fetch(url.value)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
data.value = await res.json()
} catch (e) {
error.value = e as Error
} finally {
loading.value = false
}
})
return { data, error, loading }
}
Vue Router (v4)
// router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', component: () => import('@/views/Home.vue') },
{
path: '/users/:id',
component: () => import('@/views/User.vue'),
props: true, // pass params as props
},
{
path: '/admin',
component: () => import('@/layouts/AdminLayout.vue'),
meta: { requiresAuth: true },
children: [
{ path: '', component: () => import('@/views/Dashboard.vue') },
{ path: 'settings', component: () => import('@/views/Settings.vue') },
],
},
{ path: '/:pathMatch(.*)*', component: () => import('@/views/404.vue') },
],
})
// Navigation guard
router.beforeEach((to) => {
if (to.meta.requiresAuth && !isLoggedIn()) {
return { path: '/login', query: { redirect: to.fullPath } }
}
})
export default router
// In a component
import { useRouter, useRoute } from 'vue-router'
const router = useRouter()
const route = useRoute()
// Access params, query, meta
const userId = route.params.id as string
const page = route.query.page as string
// Navigate
router.push('/home')
router.push({ name: 'user', params: { id: '123' } })
router.replace('/login')
router.go(-1) // history.back()
<!-- Template navigation -->
<RouterLink to="/">Home</RouterLink>
<RouterLink :to="{ name: 'user', params: { id: user.id } }">
Profile
</RouterLink>
<RouterLink to="/about" active-class="active">About</RouterLink>
<!-- Route outlet -->
<RouterView />
Pinia (state management)
// stores/useUserStore.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
// Composition API style (recommended)
export const useUserStore = defineStore('user', () => {
const user = ref<User | null>(null)
const isLoggedIn = computed(() => user.value !== null)
async function login(credentials: Credentials) {
const data = await authApi.login(credentials)
user.value = data.user
}
function logout() {
user.value = null
}
return { user, isLoggedIn, login, logout }
})
<!-- In a component -->
<script setup lang="ts">
import { useUserStore } from '@/stores/useUserStore'
import { storeToRefs } from 'pinia'
const userStore = useUserStore()
// storeToRefs preserves reactivity when destructuring
const { user, isLoggedIn } = storeToRefs(userStore)
// Actions can be destructured directly (not reactive)
const { login, logout } = userStore
</script>
<template>
<div v-if="isLoggedIn">Welcome, {{ user.name }}</div>
<button @click="logout">Logout</button>
</template>
Async components and Suspense
import { defineAsyncComponent } from 'vue'
// Basic async component
const HeavyChart = defineAsyncComponent(
() => import('./components/HeavyChart.vue')
)
// With loading/error states
const AsyncUser = defineAsyncComponent({
loader: () => import('./components/UserProfile.vue'),
loadingComponent: LoadingSpinner,
errorComponent: ErrorDisplay,
delay: 200, // ms before showing loading component
timeout: 5000, // ms before showing error component
})
<!-- Suspense wraps async components -->
<Suspense>
<template #default>
<AsyncUser :id="userId" />
</template>
<template #fallback>
<LoadingSpinner />
</template>
</Suspense>
Teleport and KeepAlive
<!-- Teleport — render inside #modal-root regardless of component position -->
<Teleport to="#modal-root">
<div v-if="isOpen" class="modal-overlay">
<div class="modal">
<slot />
</div>
</div>
</Teleport>
<!-- KeepAlive — cache component state when switching tabs -->
<KeepAlive :include="['TabA', 'TabB']" :max="5">
<component :is="currentTab" />
</KeepAlive>
TypeScript patterns
// Type the component instance
import type { ComponentPublicInstance } from 'vue'
const inputRef = ref<HTMLInputElement | null>(null)
// Generic composable
export function useList<T>(initial: T[] = []) {
const items = ref<T[]>(initial)
const add = (item: T) => items.value.push(item)
const remove = (index: number) => items.value.splice(index, 1)
return { items, add, remove }
}
// Typed emits (Vue 3.3+)
const emit = defineEmits<{
change: [value: string]
'update:modelValue': [val: number]
}>()
// Typed props with complex types
interface User {
id: number
name: string
roles: ('admin' | 'user')[]
}
const props = defineProps<{
user: User
onSelect?: (user: User) => void
}>()
Common mistakes
| Mistake | Problem | Fix |
|---|---|---|
const { count } = reactive(state) |
Loses reactivity | Use toRefs(state) |
props.count++ |
Mutating props | Emit event to parent |
watch(obj.value, ...) |
Won't deep-watch | Use watch(() => obj.deep, ...) or deep: true |
v-for without :key |
Incorrect DOM reuse | Always add :key with stable ID |
v-if + v-for on same element |
v-if can't access loop vars in Vue 3 |
Wrap in <template v-for> |
Using ref in reactive without .value |
Silent bug | In reactive, refs auto-unwrap only at top level |
| Returning non-reactive from composable | Lost reactivity | Return ref/reactive or toRefs(reactive(...)) |
Missing await on async setup |
Component renders before data loads | Use <Suspense> or onMounted fetch |
Options API vs Composition API
| Concern | Options API | Composition API |
|---|---|---|
| State | data() |
ref() / reactive() |
| Computed | computed: {} |
computed(() => ...) |
| Methods | methods: {} |
plain functions |
| Watchers | watch: {} |
watch() / watchEffect() |
| Lifecycle | mounted() {} |
onMounted(() => {}) |
| Props | props: {} |
defineProps<{}>() |
| Code reuse | Mixins (avoid) | Composables |
| TypeScript | Partial | Excellent |
Both APIs coexist — Options API is not deprecated, but Composition API is recommended for new code.
Frequently asked questions
ref vs reactive — which should I use?
Use ref for primitives and when you might reassign the whole value. Use reactive for grouped state where you always access properties. Many Vue teams use ref for everything for consistency.
Why does my v-for need a :key?
Without :key, Vue reuses DOM nodes by position, causing incorrect diffs when list order changes. Always use a stable unique ID, never the array index (unless list never reorders).
How is v-if different from v-show?v-if destroys/creates the DOM element. v-show toggles display: none. Use v-show for elements toggled frequently; v-if for expensive components rarely shown.
Can I use Vue 3 with the Options API?
Yes — both APIs work in Vue 3. You can even mix them in the same project (different files). The Composition API is preferred for new code and TypeScript projects.
How do I access a DOM element?
Declare const el = ref<HTMLElement | null>(null) in <script setup>, add ref="el" to the template element, then access inside onMounted(() => el.value?.focus()).
What is the difference between Pinia and Vuex?
Pinia is the official successor to Vuex for Vue 3. It's simpler (no mutations, no modules boilerplate), has first-class TypeScript support, and works with DevTools. Vuex 4 still works with Vue 3 but Pinia is recommended for all new projects.