⏳
Loading cheatsheet...
Composition API, reactivity, components, Vue Router, Pinia, directives, and server-side rendering.
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted, reactive } from 'vue'
// ── Ref (primitive reactivity) ──
const count = ref(0)
const name = ref('Alice')
const isVisible = ref(true)
console.log(count.value) // .value in script, not in template
// ── Reactive (object reactivity) ──
const user = reactive({
firstName: 'Alice',
lastName: 'Smith',
age: 30,
})
// No .value needed for reactive
console.log(user.firstName)
// ── Computed ──
const fullName = computed(() => `${user.firstName} ${user.lastName}`)
const doubled = computed(() => count.value * 2)
// Writable computed
const firstName = ref('Alice')
const lastName = ref('Smith')
const fullNameWritable = computed({
get: () => `${firstName.value} ${lastName.value}`,
set: (val) => {
const [first, ...rest] = val.split(' ')
firstName.value = first
lastName.value = rest.join(' ')
},
})
// ── Watch ──
watch(count, (newVal, oldVal) => {
console.log(`Count: ${oldVal} => ${newVal}`)
})
watch([firstName, lastName], ([first, last]) => {
console.log(`Name: ${first} ${last}`)
})
// Watch with options
watch(count, (val) => console.log(val), { immediate: true, deep: true })
// ── Lifecycle Hooks ──
onMounted(() => {
console.log('Component mounted')
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
})
// ── Template Ref ──
const inputRef = ref<HTMLInputElement | null>(null)
const focusInput = () => inputRef.value?.focus()
</script>
<template>
<input ref="inputRef" v-model="name" />
<p>Hello, {{ name }}!</p>
<button @click="count++">Count: {{ count }}</button>
<p v-if="isVisible">{{ doubled }}</p>
</template>| Feature | ref | reactive |
|---|---|---|
| Type | Any (primitives + objects) | Objects only |
| Access | .value in script | Direct property access |
| Reassign | count.value = 5 | Cannot reassign root |
| Destructure | Loses reactivity | Loses reactivity |
| Template | No .value needed | Direct access |
| Best for | Primitives, single values | Complex objects/forms |
| Hook | Timing |
|---|---|
| onBeforeMount() | Before component mounts |
| onMounted() | After DOM is ready |
| onBeforeUpdate() | Before reactive data changes |
| onUpdated() | After DOM re-rendered |
| onBeforeUnmount() | Before component unmounts |
| onUnmounted() | After component removed |
| onErrorCaptured() | Child component error caught |
| onActivated() | KeepAlive: component activated |
| onDeactivated() | KeepAlive: component deactivated |
<script setup lang="ts">
// ── Props with Types ──
const props = defineProps<{
user: {
id: number
name: string
email: string
role: 'admin' | 'user'
}
isActive?: boolean // optional
loading?: boolean
}>()
// Props with defaults
const props2 = withDefaults(defineProps<{
title: string
count?: number
color?: string
}>(), {
count: 0,
color: 'blue',
})
// ── Emits ──
const emit = defineEmits<{
(e: 'update', value: string): void
(e: 'delete', id: number): void
(e: 'change', payload: { field: string; value: string }): void
}>()
const handleInput = (e: Event) => {
emit('update', (e.target as HTMLInputElement).value)
}
// ── Expose (parent can access via ref) ──
defineExpose({
reset: () => { /* ... */ },
getValue: () => count.value,
})
// ── Slots ──
// <slot name="header">Default header</slot>
// <slot /> (default slot)
// <slot name="footer" :data="footerData" /> (scoped slot)
</script>
<template>
<div class="card" :class="{ active: isActive }">
<slot name="header">{{ title }}</slot>
<p>{{ user.name }} ({{ user.role }})</p>
<input @input="handleInput" />
<slot />
</div>
</template>
<!-- Usage -->
<!-- <UserCard :user="currentUser" @update="handleUpdate"> -->
<!-- <template #header>Custom Header</template> -->
<!-- </UserCard> --><script setup lang="ts">
import { ref, watch } from 'vue'
// ── Async Component (lazy loading) ──
import { defineAsyncComponent } from 'vue'
const HeavyChart = defineAsyncComponent(() => import('./HeavyChart.vue'))
const LazyModal = defineAsyncComponent({
loader: () => import('./Modal.vue'),
loadingComponent: () => h('div', 'Loading...'),
errorComponent: () => h('div', 'Failed to load'),
delay: 200,
timeout: 3000,
})
// ── Dynamic Component ──
const currentTab = ref('home')
const tabs = {
home: defineAsyncComponent(() => import('./HomeTab.vue')),
profile: defineAsyncComponent(() => import('./ProfileTab.vue')),
settings: defineAsyncComponent(() => import('./SettingsTab.vue')),
}
</script>
<template>
<!-- Suspense for async components -->
<Suspense>
<template #default>
<HeavyChart />
</template>
<template #fallback>
<div>Loading chart...</div>
</template>
</Suspense>
<!-- Dynamic component -->
<component :is="tabs[currentTab]" />
</template>defineAsyncComponent for code splitting. Wrap async components in Suspense with fallback UI. Use dynamic components for tabs, wizards, and conditional UI. Use slots for flexible component composition.import { ref, reactive, toRef, toRefs, isRef, unref, triggerRef,
shallowRef, readonly, isReactive } from 'vue'
// ── toRef / toRefs (destructure without losing reactivity) ──
const user = reactive({ name: 'Alice', age: 30 })
const nameRef = toRef(user, 'name') // single ref
const { name, age } = toRefs(user) // destructured refs
// ── unref (safely get value, works with ref and plain values) ──
function useFeature(featureOrRef: Ref<string> | string) {
const value = unref(featureOrRef) // .value if ref, else as-is
}
// ── shallowRef (avoid deep reactivity for performance) ──
const bigData = shallowRef({ items: [] })
// Only triggers update when .value is replaced (not deep mutations)
bigData.value = { items: [1, 2, 3] } // triggers update
bigData.value.items.push(4) // does NOT trigger update
triggerRef(bigData) // force update
// ── readonly (prevent mutations) ──
const original = reactive({ count: 0 })
const copy = readonly(original)
// copy.count = 5 // Warning: Set operation on readonly
// ── Custom Composable ──
function useCounter(initial = 0) {
const count = ref(initial)
const doubled = computed(() => count.value * 2)
const increment = () => count.value++
const decrement = () => count.value--
const reset = () => count.value = initial
return { count, doubled, increment, decrement, reset }
}
// ── Fetch Composable ──
function useFetch<T>(url: string) {
const data = ref<T | null>(null) as Ref<T | null>
const error = ref<Error | null>(null)
const loading = ref(true)
const fetchData = async () => {
loading.value = true
try {
const res = await fetch(url)
data.value = await res.json()
} catch (e) {
error.value = e as Error
} finally {
loading.value = false
}
}
fetchData()
return { data, error, loading, refresh: fetchData }
}
// Usage: const { data, loading } = useFetch('/api/users')// ── useLocalStorage Composable ──
function useLocalStorage<T>(key: string, defaultValue: T) {
const stored = localStorage.getItem(key)
const data = ref<T>(stored ? JSON.parse(stored) : defaultValue)
watch(data, (val) => {
localStorage.setItem(key, JSON.stringify(val))
}, { deep: true })
return data
}
// ── useDebounce Composable ──
function useDebounce<T>(value: Ref<T>, delay = 300): Ref<T> {
const debounced = ref(value.value) as Ref<T>
let timeout: ReturnType<typeof setTimeout>
watch(value, (newVal) => {
clearTimeout(timeout)
timeout = setTimeout(() => { debounced.value = newVal }, delay)
})
return debounced
}
// ── useClickOutside Composable ──
function useClickOutside(elementRef: Ref<HTMLElement | null>, callback: () => void) {
const handler = (e: MouseEvent) => {
if (elementRef.value && !elementRef.value.contains(e.target as Node)) {
callback()
}
}
onMounted(() => document.addEventListener('click', handler))
onUnmounted(() => document.removeEventListener('click', handler))
}
// ── watchEffect (auto-track dependencies) ──
watchEffect(() => {
// Automatically tracks all reactive references used inside
console.log(`Count is: ${count.value}`)
// Runs immediately, then whenever dependencies change
})
// ── watch vs watchEffect ──
// watch: explicit source, old/new values, lazy (not immediate)
// watchEffect: auto-track, no old value, runs immediatelytoRefs() when destructuring props or reactive objects. Use shallowRef for large objects that you replace entirely (avoid deep reactivity overhead). Use watchEffect for side effects that auto-track dependencies.import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
// ── Setup Store (Composition API style) ──
export const useUserStore = defineStore('user', () => {
// State
const user = ref<{ id: number; name: string; email: string; role: string } | null>(null)
const token = ref('')
const loading = ref(false)
const error = ref('')
// Getters (computed)
const isLoggedIn = computed(() => !!token.value)
const isAdmin = computed(() => user.value?.role === 'admin')
const displayName = computed(() => user.value?.name ?? 'Guest')
// Actions
async function login(email: string, password: string) {
loading.value = true
error.value = ''
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error)
user.value = data.user
token.value = data.token
localStorage.setItem('token', data.token)
} catch (e) {
error.value = (e as Error).message
} finally {
loading.value = false
}
}
function logout() {
user.value = null
token.value = ''
localStorage.removeItem('token')
}
return { user, token, loading, error, isLoggedIn, isAdmin, displayName, login, logout }
})
// ── Usage in Component ──
// <script setup>
// const store = useUserStore()
// store.login('alice@example.com', 'password')
// console.log(store.isLoggedIn, store.displayName)
// </script>// ── Options Store (simpler, similar to Vuex) ──
export const useCounterStore = defineStore('counter', {
state: () => ({
count: 0,
history: [] as number[],
}),
getters: {
doubled: (state) => state.count * 2,
isNegative: (state) => state.count < 0,
},
actions: {
increment() {
this.count++
this.history.push(this.count)
},
decrement() {
this.count--
this.history.push(this.count)
},
reset() {
this.count = 0
this.history = []
},
async incrementAsync() {
await new Promise(r => setTimeout(r, 1000))
this.increment()
},
},
})
// ── Store with Plugins ──
const pinia = createPinia()
pinia.use(({ store }) => {
// Persist to localStorage
const saved = localStorage.getItem(store.$id)
if (saved) store.$patch(JSON.parse(saved))
store.$subscribe((mutation, state) => {
localStorage.setItem(store.$id, JSON.stringify(state))
})
})import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router'
const routes: RouteRecordRaw[] = [
{
path: '/',
name: 'Home',
component: () => import('@/views/Home.vue'), // lazy loaded
},
{
path: '/users/:id',
name: 'UserProfile',
component: () => import('@/views/UserProfile.vue'),
props: true, // route params become props
},
{
path: '/dashboard',
name: 'Dashboard',
component: () => import('@/views/Dashboard.vue'),
meta: { requiresAuth: true, role: 'admin' },
children: [
{ path: '', name: 'DashboardHome', component: () => import('@/views/DashHome.vue') },
{ path: 'settings', name: 'Settings', component: () => import('@/views/Settings.vue') },
],
},
{ path: '/login', name: 'Login', component: () => import('@/views/Login.vue') },
{ path: '/:pathMatch(.*)*', name: 'NotFound', component: () => import('@/views/NotFound.vue') },
]
const router = createRouter({
history: createWebHistory(),
routes,
scrollBehavior(to, from, savedPosition) {
return savedPosition || { top: 0 }
},
})
// ── Navigation Guards ──
router.beforeEach((to, from) => {
const authStore = useAuthStore()
if (to.meta.requiresAuth && !authStore.isLoggedIn) {
return { name: 'Login', query: { redirect: to.fullPath } }
}
if (to.meta.role && authStore.user?.role !== to.meta.role) {
return { name: 'Home' }
}
})
export default router<script setup lang="ts">
import { useRoute, useRouter, onBeforeRouteLeave } from 'vue-router'
const route = useRoute()
const router = useRouter()
// Route params as props (when props: true in route config)
const props = defineProps<{ id: string }>()
// Programmatic navigation
const goToHome = () => router.push('/')
const goBack = () => router.back()
const replaceWith = () => router.replace('/other')
// Navigation with params
router.push({ name: 'UserProfile', params: { id: '123' } })
router.push({ path: '/users', query: { page: '2', sort: 'name' } })
// Route meta
const isAuthRequired = route.meta.requiresAuth
// In-component guard
onBeforeRouteLeave((to, from) => {
if (hasUnsavedChanges.value) {
if (!confirm('Discard changes?')) return false
}
})
</script>
<template>
<div>
<h1>User {{ props.id }}</h1>
<p>Query: {{ $route.query.page }}</p>
<router-link to="/users">Back to Users</router-link>
<router-view /> <!-- nested routes -->
</div>
</template>() => import()) for all route components. This splits your app into chunks loaded on demand. Use meta fields for auth, roles, and titles. Use scrollBehavior for consistent scroll position on navigation.