Toolmingo
Guides9 min read

Vue.js 3 Cheat Sheet: Composition API, Reactivity & Patterns

A complete Vue 3 cheat sheet — Composition API, reactivity (ref/reactive/computed/watch), components, directives, Pinia, Vue Router, and common patterns with copy-ready examples.

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: setuponBeforeMountonMounted → (updates) → onBeforeUnmountonUnmounted.


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.

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