Vue 3 is a progressive JavaScript framework for building user interfaces. This cheat sheet covers the Composition API, reactivity primitives, components, directives, Vue Router, Pinia state management, and real-world patterns — with copy-ready examples.
Quick reference
| Pattern | What it does |
|---|---|
ref(0) |
Reactive primitive value |
reactive({}) |
Reactive object (deep) |
computed(() => x.value * 2) |
Derived reactive value |
watch(src, cb) |
Side effect on change |
watchEffect(() => ...) |
Auto-tracked side effect |
defineProps<{name: string}>() |
Typed component props |
defineEmits<{click: [id: number]}>() |
Typed emit events |
defineExpose({method}) |
Expose to parent via ref |
provide('key', val) |
Inject value down tree |
inject('key') |
Consume provided value |
v-if / v-else-if / v-else |
Conditional rendering |
v-for="item in list" :key="item.id" |
List rendering |
v-model="value" |
Two-way binding |
v-bind:class / :class |
Bind attribute |
v-on:click / @click |
Bind event |
<Suspense> |
Async component loading |
<Teleport to="body"> |
Render outside parent |
<KeepAlive> |
Cache component state |
useRouter() / useRoute() |
Composable router access |
defineStore('id', () => ...) |
Pinia store (setup style) |
storeToRefs(store) |
Destructure reactive store |
onMounted(() => ...) |
Lifecycle hook |
<script setup> |
Recommended component syntax |
nextTick(() => ...) |
Run after DOM update |
toRefs(reactive({})) |
Destructure reactive object |
Setup and project creation
# Scaffold a new project
npm create vue@latest my-app
cd my-app && npm install && npm run dev
# Add Vue Router + Pinia during scaffold
# (select Yes when prompted)
# Manually add dependencies
npm install vue-router@4 pinia
Reactivity
ref — for primitives
<script setup>
import { ref } from 'vue'
const count = ref(0) // wrap in Ref<number>
const name = ref('Alice')
function increment() {
count.value++ // access via .value in <script>
}
</script>
<template>
<p>{{ count }}</p> <!-- auto-unwrapped in template -->
<button @click="increment">+1</button>
</template>
reactive — for objects
import { reactive, toRefs } from 'vue'
const state = reactive({
user: { name: 'Alice', age: 30 },
loading: false,
})
// Destructure safely — keeps reactivity
const { user, loading } = toRefs(state)
state.user.name = 'Bob' // triggers update
computed
import { ref, computed } from 'vue'
const price = ref(100)
const qty = ref(3)
const total = computed(() => price.value * qty.value)
const label = computed({
get: () => total.value.toFixed(2),
set: (v) => { price.value = parseFloat(v) / qty.value },
})
watch and watchEffect
import { ref, watch, watchEffect } from 'vue'
const query = ref('')
// watch specific source
watch(query, (newVal, oldVal) => {
console.log('changed:', newVal)
}, { immediate: true, deep: false })
// watch multiple sources
watch([query, page], ([q, p]) => fetchResults(q, p))
// watchEffect — auto-tracks dependencies
watchEffect(() => {
console.log('query is:', query.value) // re-runs when query changes
})
Components
Single File Component (SFC) with <script setup>
<!-- UserCard.vue -->
<script setup lang="ts">
import { computed } from 'vue'
interface Props {
name: string
age?: number
}
const props = defineProps<Props>()
const emit = defineEmits<{ select: [id: string] }>()
const label = computed(() => `${props.name} (${props.age ?? '?'})`)
function handleClick() {
emit('select', props.name)
}
</script>
<template>
<div class="card" @click="handleClick">
{{ label }}
</div>
</template>
Using the component
<script setup>
import UserCard from './UserCard.vue'
</script>
<template>
<UserCard name="Alice" :age="30" @select="onSelect" />
</template>
Props with defaults (withDefaults)
const props = withDefaults(defineProps<{
size?: 'sm' | 'md' | 'lg'
disabled?: boolean
}>(), {
size: 'md',
disabled: false,
})
Slots
<!-- Button.vue -->
<template>
<button>
<slot name="icon" /> <!-- named slot -->
<slot /> <!-- default slot -->
</button>
</template>
<!-- Parent -->
<Button>
<template #icon>⭐</template>
Click me
</Button>
defineExpose — expose methods to parent
// Child.vue
const reset = () => { count.value = 0 }
defineExpose({ reset })
// Parent
const childRef = ref<InstanceType<typeof Child>>()
childRef.value?.reset()
Template directives
<!-- Conditional -->
<div v-if="isAdmin">Admin panel</div>
<div v-else-if="isMod">Mod tools</div>
<div v-else>User view</div>
<!-- Loop — always use :key -->
<li v-for="(item, index) in items" :key="item.id">
{{ index }}: {{ item.name }}
</li>
<!-- Two-way binding -->
<input v-model="searchQuery" />
<input v-model.trim="name" /> <!-- trim modifier -->
<input v-model.number="age" /> <!-- cast to number -->
<input v-model.lazy="bio" /> <!-- sync on change, not input -->
<!-- Class and style binding -->
<div :class="{ active: isActive, error: hasError }"></div>
<div :class="[baseClass, conditionalClass]"></div>
<div :style="{ color: textColor, fontSize: size + 'px' }"></div>
<!-- v-once — render once, skip future updates -->
<h1 v-once>{{ title }}</h1>
<!-- v-html — render raw HTML (XSS risk — only trusted content) -->
<div v-html="sanitizedMarkup"></div>
<!-- Event modifiers -->
<form @submit.prevent="onSubmit">...</form>
<div @click.stop="handler">...</div>
<input @keyup.enter="search" />
Lifecycle hooks
import {
onBeforeMount, onMounted,
onBeforeUpdate, onUpdated,
onBeforeUnmount, onUnmounted,
} from 'vue'
onMounted(() => {
// DOM is available
fetchData()
})
onUnmounted(() => {
// cleanup subscriptions, timers
clearInterval(timer)
})
Lifecycle order: setup → onBeforeMount → onMounted → (updates) → onBeforeUnmount → onUnmounted.
Composables (reusable logic)
// composables/useCounter.ts
import { ref } from 'vue'
export function useCounter(initial = 0) {
const count = ref(initial)
const increment = () => count.value++
const reset = () => { count.value = initial }
return { count, increment, reset }
}
// Usage in any component
import { useCounter } from '@/composables/useCounter'
const { count, increment } = useCounter(10)
Async composable with loading state
// composables/useFetch.ts
import { ref } from 'vue'
export function useFetch<T>(url: string) {
const data = ref<T | null>(null)
const loading = ref(false)
const error = ref<Error | null>(null)
async function execute() {
loading.value = true
error.value = null
try {
const res = await fetch(url)
data.value = await res.json() as T
} catch (e) {
error.value = e as Error
} finally {
loading.value = false
}
}
execute()
return { data, loading, error, execute }
}
Provide / Inject
// Parent or plugin
import { provide, ref } from 'vue'
const theme = ref<'light' | 'dark'>('light')
provide('theme', theme) // provide reactive value
// Deep child — no prop drilling needed
import { inject } from 'vue'
const theme = inject<Ref<string>>('theme', ref('light'))
Vue Router 4
// router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', component: () => import('@/views/Home.vue') },
{ path: '/about', component: () => import('@/views/About.vue') },
{ path: '/user/:id', component: () => import('@/views/User.vue') },
{ path: '/:pathMatch(.*)*', component: () => import('@/views/NotFound.vue') },
],
})
export default router
// In a component
import { useRouter, useRoute } from 'vue-router'
const router = useRouter()
const route = useRoute()
const userId = route.params.id as string
const tab = route.query.tab as string
router.push('/about')
router.push({ name: 'user', params: { id: 42 } })
router.replace('/login')
router.back()
<template>
<!-- Link component -->
<RouterLink to="/">Home</RouterLink>
<RouterLink :to="{ name: 'user', params: { id: 1 } }">Profile</RouterLink>
<!-- Route outlet -->
<RouterView />
</template>
Navigation guards
// Per-route guard
const routes = [{
path: '/dashboard',
component: Dashboard,
beforeEnter: (to, from) => {
if (!isLoggedIn()) return '/login'
},
}]
// Global guard
router.beforeEach((to, from) => {
if (to.meta.requiresAuth && !isLoggedIn()) {
return { name: 'login', query: { redirect: to.fullPath } }
}
})
Pinia (state management)
// stores/useCartStore.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useCartStore = defineStore('cart', () => {
const items = ref<CartItem[]>([])
const total = computed(() =>
items.value.reduce((sum, item) => sum + item.price * item.qty, 0)
)
function addItem(item: CartItem) {
const existing = items.value.find(i => i.id === item.id)
if (existing) existing.qty++
else items.value.push({ ...item, qty: 1 })
}
function removeItem(id: string) {
items.value = items.value.filter(i => i.id !== id)
}
return { items, total, addItem, removeItem }
})
<script setup>
import { storeToRefs } from 'pinia'
import { useCartStore } from '@/stores/useCartStore'
const cart = useCartStore()
const { items, total } = storeToRefs(cart) // reactive destructure
</script>
<template>
<p>Total: {{ total }}</p>
<button @click="cart.addItem(product)">Add</button>
</template>
Special components
<!-- Teleport — render in a different DOM node -->
<Teleport to="body">
<div class="modal">...</div>
</Teleport>
<!-- KeepAlive — cache inactive components -->
<KeepAlive :include="['UserList']" :max="5">
<component :is="activeTab" />
</KeepAlive>
<!-- Suspense — async component loading -->
<Suspense>
<template #default>
<AsyncDashboard />
</template>
<template #fallback>
<LoadingSpinner />
</template>
</Suspense>
Common patterns
Dynamic component
<script setup>
import TabA from './TabA.vue'
import TabB from './TabB.vue'
const tabs = { a: TabA, b: TabB }
const current = ref('a')
</script>
<template>
<component :is="tabs[current]" />
</template>
v-model on custom component
<!-- BaseInput.vue -->
<script setup>
const model = defineModel<string>() // Vue 3.4+ shorthand
</script>
<template>
<input :value="model" @input="model = $event.target.value" />
</template>
<!-- Parent -->
<BaseInput v-model="searchText" />
Async setup with <Suspense>
<script setup>
// top-level await is allowed in <script setup>
const { data } = await useFetch('/api/user')
</script>
Common mistakes
| Mistake | Fix |
|---|---|
Destructuring reactive() without toRefs() |
Use const { x } = toRefs(state) or keep state.x |
Forgetting .value on ref in <script> |
.value always required in JS; not in template |
Missing :key in v-for |
Always bind :key="item.id" |
| Mutating props directly | Emit an event and let parent update |
Using reactive() for primitives |
Use ref() for strings, numbers, booleans |
watch not triggering on nested object |
Add { deep: true } or watch a specific property |
v-html with user input |
Sanitize with DOMPurify first — XSS risk |
Calling composable outside setup() |
Composables must be called at the top of setup |
Vue 2 → Vue 3 migration
| Vue 2 | Vue 3 |
|---|---|
Vue.component() global |
app.component() |
new Vue() |
createApp() |
Options API data() |
ref() / reactive() in <script setup> |
this.$emit |
defineEmits |
this.$refs.foo |
const foo = ref<...>() |
Vue.set(obj, key, val) |
Direct assignment (reactive tracks it) |
Filters | currency |
Computed or method (filters removed) |
EventBus |
mitt library or Pinia |
| Vuex | Pinia (recommended) |
Common mistakes
| Mistake | Fix |
|---|---|
Using <script setup> but still calling defineComponent |
Not needed in <script setup> |
Pinia store destructured without storeToRefs |
const { x } = storeToRefs(store) |
Forgetting app.use(router) and app.use(pinia) in main.ts |
Register plugins before app.mount |
FAQ
Q: Options API vs Composition API — which to use?
Composition API (<script setup>) is recommended for new projects. It gives better TypeScript support, easier code reuse via composables, and smaller bundles. Options API still works fine and is not deprecated.
Q: When to use ref vs reactive?
Use ref for primitives (numbers, strings, booleans) and single values. Use reactive for objects where you don't need to replace the whole object. If you need to replace the top-level reference, ref is safer since reactive loses reactivity on reassignment.
Q: How do I share state between sibling components?
Use Pinia for global/shared state. For locally shared state between a parent and its children, use provide/inject. Avoid prop drilling beyond 2–3 levels.
Q: How do I fetch data when a component mounts?
Use onMounted with async/await, or use a composable like useFetch. For SSR (Nuxt), use useFetch or useAsyncData which run on both server and client.
Q: What is storeToRefs and why is it needed?
Pinia state is reactive, so destructuring it loses reactivity. storeToRefs converts each property to a ref that stays connected to the store. Actions don't need storeToRefs — destructure them directly.
Q: Vue 3 vs Nuxt — when do I need Nuxt?
Use Vue 3 for SPAs and client-only apps. Use Nuxt when you need server-side rendering (SSR), static site generation (SSG), file-based routing, or built-in SEO optimisation.