Toolmingo
Guides12 min read

Vue 3 Cheat Sheet: Composition API, Directives & Patterns

A complete Vue 3 cheat sheet — Composition API (setup, ref, reactive, computed, watch), directives, components, routing, Pinia state, lifecycle hooks, and common patterns with copy-ready examples.

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.

Related tools

Keep reading

All Toolmingotools are free & run in your browser

No sign-up, no upload, no watermark. Your files never leave your device.

Browse all tools