Skip to content

Vue 3 Шпаргалка

Progressive JavaScript framework for building user interfaces.

01

Getting Started

Hello World & Comments

A Vue 3 app starts with createApp() and mount() to a DOM element. Interpolation uses double curly braces {{ }} to render reactive data. HTML comments appear in the rendered DOM; for comments that don't leak, use /* */ inside <script> blocks.

vue3
<!-- index.html -->
<div id="app">
  <p>{{ message }}</p>
  <!-- This is an HTML comment, visible in DOM -->
</div>

<script type="module">
  import { createApp } from 'vue'

  createApp({
    data() {
      return { message: 'Hello Vue 3!' }
    }
  }).mount('#app')
</script>

SFC Structure (Single File Component)

An SFC bundles template, script, and style in one .vue file. <script setup> is the recommended syntax for Composition API — concise and zero boilerplate. scoped styles only apply to the current component, preventing leakage. Each SFC compiles to a JavaScript module.

vue3
<template>
  <button @click="count++">Count: {{ count }}</button>
</template>

<script setup>
import { ref } from 'vue'
const count = ref(0)
</script>

<style scoped>
button { color: #42B883; }
</style>

App Instance & Mount

createApp returns an application instance scoped to itself — no global Vue state, so you can mount multiple independent apps on one page. Register plugins, provide global values, and set config before calling mount(). After mount, further config changes won't take effect.

vue3
import { createApp } from 'vue'
import App from './App.vue'

const app = createApp(App)

// global config / plugins before mounting
app.config.errorHandler = (err) => console.error(err)
app.provide('apiKey', '12345')

app.mount('#app')

Project Scaffolding (Vite)

npm create vue@latest is the official scaffolding tool, offering optional TypeScript, Router, Pinia, and Vitest setup. It uses Vite for instant dev server start and HMR. create-vite is the lower-level tool if you want a minimal template without Vue's opinions.

vue3
# scaffold a new Vue project with Vite
npm create vue@latest my-app
cd my-app
npm install
npm run dev       # start dev server (HMR)
npm run build     # production build to /dist

# or with create-vite directly
npm create vite@latest my-app -- --template vue-ts

defineOptions & defineProps

defineProps, defineEmits, and defineOptions are compile-time macros — they don't need to be imported. defineProps declares the component's prop contract with runtime validation. defineOptions lets you set name, inheritAttrs, and other options that aren't expressible in <script setup> directly.

vue3
<script setup>
// declare props with types and defaults
const props = defineProps({
  title: String,
  count: { type: Number, default: 0 }
})

// declare emits
const emit = defineEmits(['update', 'delete'])

// additional non-reactive options
defineOptions({ name: 'MyButton', inheritAttrs: false })
</script>
02

Template Syntax

Text Interpolation

{{ }} renders text safely (HTML-escaped). v-once freezes a binding after first render for static content. v-html injects raw HTML — only use on trusted content to avoid XSS. Inside {{ }} you can use any single JavaScript expression, but not statements like assignments or var declarations.

vue3
<template>
  <p>{{ message }}</p>
  <!-- renders the value of message -->

  <!-- one-time binding (no updates after render) -->
  <span v-once>{{ initialCount }}</span>

  <!-- raw HTML (sanitize to avoid XSS!) -->
  <div v-html="rawHtmlContent"></div>

  <!-- expressions are supported -->
  <p>{{ count * 2 }}</p>
  <p>{{ isReady ? 'Yes' : 'No' }}</p>
  <p>{{ message.split('').reverse().join('') }}</p>
</template>

Attribute Binding

v-bind (shorthand :) binds attributes reactively. For class, you can pass an object (truthy keys apply), array, or string. style accepts an object with camelCase or kebab-case keys. Dynamic argument syntax :[expr] lets the attribute name itself be reactive — useful for event names or localized attributes.

vue3
<template>
  <!-- bind an attribute -->
  <img v-bind:src="imageUrl" />
  <img :src="imageUrl" />  <!-- shorthand -->

  <!-- bind class -->
  <div :class="{ active: isActive, error: hasError }"></div>
  <div :class="['btn', isActive && 'active']"></div>

  <!-- bind style -->
  <div :style="{ color: textColor, fontSize: size + 'px' }"></div>

  <!-- dynamic attribute name -->
  <button :[eventName]="handler">Click</button>
</template>

Boolean & Multi-value Attributes

For boolean attributes like disabled or checked, Vue renders the attribute only when the value is truthy. class and style are special: Vue merges static and bound values rather than replacing them. For form inputs, prefer v-model over manually binding :value and @input.

vue3
<template>
  <!-- boolean attribute: presence depends on value -->
  <input :disabled="isDisabled" />
  <button :disabled="!canSubmit">Submit</button>

  <!-- class merging: bound class merges with static class -->
  <div class="card" :class="{ highlighted: isFeatured }"></div>

  <!-- style merging: bound style merges with static style -->
  <div style="color: red" :style="{ fontSize: '14px' }"></div>

  <!-- form attributes: value is bound with v-model instead -->
  <input v-model="text" />
</template>

Modifiers & Expressions

Modifiers are postfix denoted by a dot, indicating special handling. Event modifiers (.prevent, .stop, .once, .self) wrap the handler with the corresponding DOM method. Key modifiers (.enter, .esc, .ctrl) filter events by key. v-model modifiers (.trim, .number, .lazy) transform the input value before assignment.

vue3
<template>
  <!-- .prevent calls event.preventDefault() -->
  <form @submit.prevent="onSubmit">...</form>

  <!-- .stop calls event.stopPropagation() -->
  <button @click.stop="doStuff">Click</button>

  <!-- .once runs handler only one time -->
  <button @click.once="init">Init</button>

  <!-- .trim and .lazy modifiers on v-model -->
  <input v-model.trim="email" />
  <input v-model.lazy="text" />

  <!-- chain multiple modifiers -->
  <input @keyup.enter.ctrl="onCombo" />
</template>

Template Refs

Template refs give direct access to DOM elements. Declare a ref() with the same name as the ref attribute — Vue assigns the DOM node after mount. Access inside onMounted (or later); before mount, the ref is null. For v-for, the ref becomes an array of elements. Avoid reaching for refs when reactive data flows would suffice.

vue3
<script setup>
import { ref, onMounted } from 'vue'

// declare a ref matching the template ref attribute
const inputEl = ref(null)

onMounted(() => {
  // access the DOM node after mount
  inputEl.value.focus()
})
</script>

<template>
  <input ref="inputEl" />
</template>
03

Directives

v-if / v-else-if / v-else

v-if conditionally renders elements — they are added/removed from the DOM. v-else-if and v-else chain with a preceding v-if. Wrap multiple elements in a <template v-if> when you don't want an extra wrapper element. v-if has higher toggle cost (DOM creation) but lower initial cost than v-show when the condition is false.

vue3
<template>
  <div v-if="type === 'A'">Type A</div>
  <div v-else-if="type === 'B'">Type B</div>
  <div v-else-if="type === 'C'">Type C</div>
  <div v-else>Unknown type</div>

  <!-- use <template> for invisible wrapper -->
  <template v-if="showHeader">
    <h1>Title</h1>
    <p>Subtitle</p>
  </template>
</template>

v-show vs v-if

Use v-if when the condition rarely changes — elements are destroyed and recreated, saving render cost when hidden. Use v-show for frequently toggled elements (tabs, dropdowns) — the element stays in DOM and only display:none toggles, so toggling is cheap. v-show doesn't work with <template> or v-else.

vue3
<template>
  <!-- v-if removes from DOM when false -->
  <p v-if="isVisible">I'm created/destroyed</p>

  <!-- v-show just toggles display:none -->
  <p v-show="isVisible">I'm always in DOM, just hidden</p>
</template>

<script setup>
import { ref } from 'vue'
const isVisible = ref(true)
</script>

v-for & :key

v-for iterates arrays, objects, or a number range. The :key attribute is REQUIRED for track-by identity — it lets Vue reuse DOM nodes efficiently during reordering. Use a stable unique id as key; never use the array index (breaks when items move). v-for has higher priority than v-if on the same element — avoid using both on one element.

vue3
<template>
  <!-- iterate over array -->
  <li v-for="item in items" :key="item.id">
    {{ item.name }}
  </li>

  <!-- iterate with index -->
  <li v-for="(item, index) in items" :key="item.id">
    {{ index }}: {{ item.name }}
  </li>

  <!-- iterate over object -->
  <li v-for="(value, key) in user" :key="key">
    {{ key }}: {{ value }}
  </li>

  <!-- range (1 to n) -->
  <span v-for="n in 10" :key="n">{{ n }}</span>
</template>

v-model Bindings

v-model is two-way binding for form inputs: it syncs the input value with reactive state. A single checkbox binds to a boolean; multiple checkboxes with the same v-model bind to an array of checked values. Use modifiers: .lazy (sync on change instead of input), .number (cast to number), .trim (strip whitespace).

vue3
<template>
  <!-- text input -->
  <input v-model="text" />

  <!-- multi-line textarea -->
  <textarea v-model="message"></textarea>

  <!-- checkbox (single = boolean, multiple = array) -->
  <input type="checkbox" v-model="isChecked" />
  <input type="checkbox" value="apple" v-model="fruits" />
  <input type="checkbox" value="banana" v-model="fruits" />

  <!-- radio -->
  <input type="radio" value="A" v-model="picked" />

  <!-- select -->
  <select v-model="selected">
    <option value="a">A</option>
    <option value="b">B</option>
  </select>
</template>

Custom Directives

Custom directives let you directly manipulate DOM elements. In <script setup>, any variable named vSomething (camelCase) is auto-available as v-something in the template. The directive object has lifecycle hooks: mounted, updated, unmounted, etc. Prefer components over directives when you need reactivity — directives are for low-level DOM work like focus, tooltips, or drag.

vue3
<script setup>
// local directive: function form (mounted + updated)
const vFocus = {
  mounted: (el) => el.focus()
}

// global registration
// app.directive('focus', { mounted: el => el.focus() })
</script>

<template>
  <input v-focus />
</template>
04

Computed Properties

Basic Computed

computed creates a cached getter that re-evaluates only when its dependencies change. Unlike a method call, repeated access returns the cached value until a dependency updates. In <script>, access via .value; in templates, Vue auto-unwraps. Use computed over methods whenever the value depends on reactive state — it's faster and declarative.

vue3
<script setup>
import { ref, computed } from 'vue'

const firstName = ref('John')
const lastName = ref('Doe')

// computed caches based on dependencies
const fullName = computed(() => {
  return firstName.value + ' ' + lastName.value
})

// access via .value in script
console.log(fullName.value)  // 'John Doe'
</script>

<template>
  <!-- use without .value in template -->
  <p>{{ fullName }}</p>
</template>

Writable Computed

By default computed is read-only, but you can provide a getter and setter. The setter is invoked when you assign to .value. This is useful for two-way binding on derived state, e.g. syncing a full-name input back to first/last name fields. Be careful to avoid infinite loops — setters should not directly set the computed's own dependencies in a way that re-triggers itself.

vue3
<script setup>
import { ref, computed } from 'vue'

const firstName = ref('John')
const lastName = ref('Doe')

const fullName = computed({
  get() {
    return firstName.value + ' ' + lastName.value
  },
  set(newValue) {
    [firstName.value, lastName.value] = newValue.split(' ')
  }
})

// assignment triggers the setter
fullName.value = 'Jane Smith'
</script>

Computed vs Methods

Computed properties cache their result based on dependencies — they only re-run when items changes. Methods re-run on every render regardless of what changed. Use computed for values derived from reactive state; use methods for actions that take parameters or have side effects. Computed also makes intent clearer: 'this value is derived' vs 'do this thing'.

vue3
<script setup>
import { ref, computed } from 'vue'

const items = ref([1, 2, 3, 4, 5])

// computed: cached, re-runs only if items changes
const doubled = computed(() =>
  items.value.map(x => x * 2)
)

// method: re-runs on every render
function getDoubled() {
  return items.value.map(x => x * 2)
}
</script>

<template>
  <!-- cached, no recompute on unrelated re-renders -->
  <p>{{ doubled }}</p>

  <!-- re-invoked on every render -->
  <p>{{ getDoubled() }}</p>
</template>

Computed with Getter Pitfalls

Computed getters must be pure: no side effects (no mutations, no async, no fetches). Don't call Date.now() or Math.random() — they aren't reactive so the computed won't update. If you need a side effect, use a watcher instead. If you need async, computed won't await — use watch + a ref, or async setup with Suspense.

vue3
<script setup>
import { ref, computed } from 'vue'

const date = ref(new Date())

// BAD: non-reactive Date.now() — won't update
const badTime = computed(() => Date.now())

// BAD: side effect inside computed
const count = ref(0)
const bad = computed(() => {
  count.value++   // never mutate state in computed!
  return count.value
})

// GOOD: pure function of reactive state
const formatted = computed(() =>
  date.value.toLocaleDateString()
)
</script>

Computed in Options API

In Options API, declare computed in the computed option. They're accessed as this.total (no .value, no parentheses) — Vue auto-unwraps. Writable computed uses get/set functions. Computed properties are exposed on the component instance alongside data and methods, all accessible via this.

vue3
<script>
export default {
  data() {
    return { price: 100, quantity: 2 }
  },
  computed: {
    total() {
      return this.price * this.quantity
    },
    discounted: {
      get() { return this.total * 0.9 },
      set(v) { this.price = v / 0.9 / this.quantity }
    }
  },
  methods: {
    checkout() { console.log(this.total) }
  }
}
</script>
05

Watchers

Basic watch

watch runs a callback when the watched source changes. The source can be a ref, a reactive object's property (via getter), or an array of sources. The callback receives (newValue, oldValue). Use a getter () => x.y to watch nested reactive properties. Watchers are for side effects (fetch, log, persist) — not for deriving state (use computed for that).

vue3
<script setup>
import { ref, watch } from 'vue'

const question = ref('')

// watch a ref
watch(question, (newValue, oldValue) => {
  console.log('changed from', oldValue, 'to', newValue)
})

// watch a getter (computed source)
watch(
  () => question.value.length,
  (newLen) => {
    if (newLen > 100) console.warn('Too long!')
  }
)
</script>

Deep Watch & Immediate

deep: true makes watch fire on changes to any nested property of an object — useful but expensive on large structures. immediate: true fires the callback immediately on setup (oldValue is undefined on first call). Avoid deep watch on huge objects — prefer watching a specific getter () => obj.specificField for better performance.

vue3
<script setup>
import { reactive, watch } from 'vue'

const user = reactive({
  name: 'Alice',
  address: { city: 'NYC', zip: '10001' }
})

// deep: watch nested property changes
watch(
  () => user.address,
  (newAddr) => console.log('address changed', newAddr),
  { deep: true }
)

// immediate: run callback right away
watch(
  user,
  (val) => saveToServer(val),
  { deep: true, immediate: true }
)
</script>

watchEffect (Auto-tracked)

watchEffect runs the callback immediately and auto-tracks any reactive dependencies accessed inside it. Whenever any tracked dep changes, the callback re-runs. Unlike watch, you don't specify a source — it's inferred. Use watchEffect when you don't need the oldValue and want automatic dependency tracking. Useful for setting up subscriptions that read multiple reactive sources.

vue3
<script setup>
import { ref, watchEffect } from 'vue'

const a = ref(1)
const b = ref(2)

// runs immediately and tracks dependencies automatically
watchEffect(() => {
  console.log('a + b =', a.value + b.value)
})
// logs: a + b = 3

a.value = 10  // logs: a + b = 12

// stop the watcher
const stop = watchEffect(() => { /* ... */ })
stop()  // cleanup
</script>

watch vs watchEffect

Choose watch when you need: oldValue, lazy execution (skip the initial run), or to watch a specific source explicitly. Choose watchEffect when: you want immediate execution, you don't need oldValue, or you have side effects touching multiple reactive values and want auto-tracking. watch with an array source gives you arrays of new/old values in the callback.

vue3
<script setup>
import { ref, watch, watchEffect } from 'vue'

const count = ref(0)

// watch: lazy, explicit source, has oldValue
watch(count, (newVal, oldVal) => {
  console.log('count changed:', oldVal, '->', newVal)
})

// watchEffect: eager, auto-tracked, no oldValue
watchEffect(() => {
  console.log('count is', count.value)
})
// logs immediately: count is 0

// watch multiple sources
const a = ref(1), b = ref(2)
watch([a, b], ([newA, newB], [oldA, oldB]) => {
  console.log('a or b changed')
})
</script>

Watcher Cleanup & Flush

The onCleanup callback (3rd arg) registers a cleanup function that runs before the next watcher invocation — perfect for cancelling in-flight requests, clearing timers, or unsubscribing. flush: 'post' runs the watcher after Vue updates the DOM (useful when you need to read updated DOM). 'sync' runs synchronously on dep change — rarely needed and can cause performance issues.

vue3
<script setup>
import { ref, watch } from 'vue'

const id = ref(1)

// cleanup function: runs before next callback
watch(id, (newId, oldId, onCleanup) => {
  const controller = new AbortController()
  fetch(`/api/user/${newId}`, { signal: controller.signal })
    .then(r => r.json())

  // cancel previous request when id changes again
  onCleanup(() => controller.abort())
}, { flush: 'post' })

// flush: 'pre' (default) | 'post' (after DOM update) | 'sync'
</script>
06

Conditional Rendering

v-if vs v-show

v-if truly adds/removes elements from the DOM — higher initial cost but zero cost when hidden. v-show always renders the element and toggles display:none — cheaper toggling but the element exists even when hidden. Use v-if for conditionals that rarely change (or when hidden elements shouldn't exist at all). Use v-show for frequently toggled UI like tabs, dropdowns, modals.

vue3
<template>
  <!-- v-if: element removed from DOM when false -->
  <div v-if="isLoggedIn">Welcome back</div>

  <!-- v-show: element stays, display toggles -->
  <div v-show="isLoggedIn">Welcome back</div>
</template>

<!-- v-show compiles to: -->
<!-- <div style="display: none;">Welcome back</div> when false -->

<!-- v-if does not render the element at all when false -->

v-if with v-else Chain

v-else-if and v-else must immediately follow a v-if (or another v-else-if) element — no other element can be between them. The chain evaluates top to bottom and stops at the first truthy condition. The final v-else catches all remaining cases. This pattern is great for status-based UI (loading, error, empty, success).

vue3
<template>
  <div v-if="status === 'loading'">Loading...</div>
  <div v-else-if="status === 'error'">Error occurred</div>
  <div v-else-if="status === 'empty'">No items</div>
  <div v-else>
    <ul>
      <li v-for="item in items" :key="item.id">{{ item.name }}</li>
    </ul>
  </div>
</template>

<template> Wrapper for v-if

<template> is a logical wrapper that doesn't render to the DOM — perfect for grouping multiple elements under one v-if or v-for without adding extra HTML. This keeps your markup semantic (no stray divs). For v-for on template, you still need :key on the template element itself.

vue3
<template>
  <!-- group elements under one condition, no wrapper div -->
  <template v-if="showProfile">
    <h2>{{ user.name }}</h2>
    <p>{{ user.bio }}</p>
    <button @click="edit">Edit</button>
  </template>

  <!-- combine v-if with v-for on template -->
  <template v-for="item in items" :key="item.id">
    <dt>{{ item.term }}</dt>
    <dd>{{ item.def }}</dd>
  </template>
</template>

v-if vs v-for Priority

Never use v-if and v-for on the same element. In Vue 3, v-if has higher priority, so it runs before v-for — the loop variable isn't available yet, causing errors. Instead, filter the array with a computed property (preferred) or inline .filter(). Use a wrapping <template v-for> with an inner v-if if you must.

vue3
<template>
  <!-- AVOID: v-if and v-for on same element -->
  <!-- In Vue 3, v-if has higher priority than v-for -->
  <!-- so 'item' is not defined when v-if evaluates -->
  <li v-for="item in items" v-if="item.active" :key="item.id">
    {{ item.name }}
  </li>

  <!-- GOOD: filter first, then iterate -->
  <li
    v-for="item in items.filter(i => i.active)"
    :key="item.id"
  >
    {{ item.name }}
  </li>

  <!-- BEST: use computed for the filtered list -->
</template>

Transition with v-if

Wrap a v-if (or v-show) element in <Transition> to animate enter/leave. Vue adds classes at the right moments: v-enter-from, v-enter-active, v-enter-to (and v-leave-*). Define CSS transitions on these classes. name='fade' replaces the 'v-' prefix with 'fade-'. Transition only supports a single child element — use TransitionGroup for lists.

vue3
<template>
  <Transition name="fade">
    <p v-if="show">Hello</p>
  </Transition>
</template>

<style>
.fade-enter-active, .fade-leave-active {
  transition: opacity 0.3s;
}
.fade-enter-from, .fade-leave-to {
  opacity: 0;
}
</style>
07

List Rendering

v-for with Arrays

v-for iterates arrays, exposing each item and (optionally) the index. ALWAYS bind :key to a stable unique value like item.id — this lets Vue match old and new nodes during reordering, preserving component state and DOM efficiently. Using index as key breaks when items are inserted/removed/reordered, causing subtle bugs.

vue3
<script setup>
import { ref } from 'vue'
const items = ref([
  { id: 1, name: 'Apple' },
  { id: 2, name: 'Banana' },
  { id: 3, name: 'Cherry' }
])
</script>

<template>
  <!-- always provide :key with stable unique id -->
  <li v-for="item in items" :key="item.id">
    {{ item.name }}
  </li>

  <!-- with index (avoid as :key) -->
  <li v-for="(item, index) in items" :key="item.id">
    {{ index + 1 }}. {{ item.name }}
  </li>
</template>

v-for with Objects & Range

v-for over an object iterates values by default; the second arg is the key, third is index. Object iteration order follows Object.keys() (insertion order for string keys). v-for with a number n iterates from 1 to n (inclusive, not 0 to n-1). For filtered or sorted lists, prefer a computed property over inline logic for readability and caching.

vue3
<template>
  <!-- iterate object: (value, key, index) -->
  <div v-for="(value, key, index) in user" :key="key">
    {{ index }}. {{ key }}: {{ value }}
  </div>

  <!-- iterate a number range (1 to n inclusive) -->
  <span v-for="n in 10" :key="n">{{ n }} </span>
  <!-- 1 2 3 4 5 6 7 8 9 10 -->

  <!-- use v-for of computed for derived lists -->
  <li v-for="todo in doneTodos" :key="todo.id">
    {{ todo.text }}
  </li>
</template>

List Change Detection

Vue 3 uses Proxy-based reactivity, so mutating arrays with push, pop, splice, sort, reverse all trigger updates — no special caveats (unlike Vue 2). Replacing the whole array (items.value = newArray) also works because the ref's value setter triggers reactivity. Filter/map produce new arrays — assign them back to update.

vue3
<script setup>
import { ref } from 'vue'

const items = ref([1, 2, 3])

// MUTATION methods (Vue 3 detects these via Proxy)
items.value.push(4)        // add
items.value.pop()          // remove last
items.value.splice(1, 1)   // remove at index
items.value.sort()         // sort

// REPLACEMENT (always reactive)
items.value = items.value.filter(n => n > 1)
items.value = [...items.value, 5]
</script>

TransitionGroup for Lists

TransitionGroup animates v-for lists: enter, leave, AND move (when items change position). Unlike Transition, it renders a real element (set via tag). Each child MUST have a unique :key. The .list-move class animates items sliding to their new positions when the list reorders — a polished effect that plain CSS can't achieve. leave-active needs position: absolute for smooth moves.

vue3
<template>
  <TransitionGroup name="list" tag="ul">
    <li v-for="item in items" :key="item.id">
      {{ item.text }}
    </li>
  </TransitionGroup>
</template>

<style>
.list-enter-active, .list-leave-active {
  transition: all 0.5s ease;
}
.list-enter-from, .list-leave-to {
  opacity: 0;
  transform: translateX(30px);
}
/* animate items moving to new positions */
.list-move {
  transition: transform 0.5s ease;
}
</style>

Performance: v-memo

v-memo is an optimization hint. Vue skips re-rendering the element unless one of the values in the dependency array changes. For large lists where most items don't change on each update, this can dramatically reduce re-renders. Only use it when you've measured a performance problem — premature optimization adds complexity and risk of stale UI.

vue3
<template>
  <!-- skip re-render of this item unless 'id' or 'selected' changes -->
  <div
    v-for="item in hugeList"
    :key="item.id"
    v-memo="[item.id, item.id === selectedId]"
  >
    {{ item.name }} — {{ item.id === selectedId ? '★' : '' }}
  </div>
</template>
08

Event Handling

Inline Handlers

@click (shorthand for v-on:click) binds a click handler. Inline statements (count++) work for simple cases. A method name (greet) auto-receives the native event as its first argument. To pass custom arguments AND the event, call the method explicitly with $event: greet('hi', $event).

vue3
<script setup>
import { ref } from 'vue'
const count = ref(0)
const name = ref('Vue')

function greet(event) {
  alert('Hello ' + name.value)
  // event is the native DOM event
  console.log(event.target.tagName)
}
</script>

<template>
  <!-- inline statement -->
  <button @click="count++">Add</button>

  <!-- method reference (auto-receives event) -->
  <button @click="greet">Greet</button>

  <!-- call with custom args (use $event for native event) -->
  <button @click="greet($event)">Greet</button>
</template>

Event Modifiers

Event modifiers chain after the event name with a dot. .prevent and .stop are most common (form submission, event bubbling). .self ensures the handler fires only when the element itself (not a child) is clicked. .passive tells the browser the handler won't call preventDefault, enabling smoother scrolling — important for touch/scroll handlers.

vue3
<template>
  <!-- .prevent: call preventDefault() -->
  <form @submit.prevent="onSubmit">...</form>

  <!-- .stop: call stopPropagation() -->
  <div @click="outer">
    <button @click.stop="inner">Won't bubble</button>
  </div>

  <!-- .self: only trigger if event.target === current element -->
  <div @click.self="onSelf">Only direct clicks</div>

  <!-- .once: handler removed after first invocation -->
  <button @click.once="init">Init once</button>

  <!-- .capture: use capture phase instead of bubble -->
  <div @click.capture="onCapture">...</div>

  <!-- .passive: improve scroll perf (no preventDefault) -->
  <div @scroll.passive="onScroll">...</div>
</template>

Key & System Modifiers

Key modifiers filter keyboard events by key name (.enter, .esc, .delete, .space, .tab, .up, .down, .left, .right). System modifiers (.ctrl, .alt, .shift, .meta) require that key to be held. .exact ensures no OTHER system modifiers are held — @click.ctrl.exact fires only on Ctrl+Click, not Ctrl+Shift+Click. Use kebab-case for keys Vue doesn't alias (page-down).

vue3
<template>
  <!-- key modifiers: only fire on specific keys -->
  <input @keyup.enter="submit" />
  <input @keyup.esc="cancel" />
  <input @keyup.delete="remove" />

  <!-- system modifiers: ctrl, alt, shift, meta -->
  <button @click.ctrl="openInNewTab">Ctrl+Click</button>
  <input @keyup.alt.enter="altEnter" />

  <!-- .exact: no other modifiers can be pressed -->
  <button @click.ctrl.exact="onCtrlOnly">Ctrl only</button>

  <!-- any key code via kebab-case (deprecated but works) -->
  <input @keyup.page-down="onPageDown" />
</template>

Custom Events with emit

Child components emit events to communicate upward. defineEmits declares the event names (used for validation and IDE hints). The parent listens with @event-name. Payloads are passed as additional arguments to emit and received by the parent's handler. By convention, use kebab-case event names in templates (my-event), camelCase in defineEmits (myEvent) — Vue auto-converts.

vue3
<!-- ChildButton.vue -->
<script setup>
const emit = defineEmits(['increment', 'delete'])

function onClick() {
  // emit with payload
  emit('increment', 1)
}
</script>

<template>
  <button @click="onClick">+1</button>
</template>

<!-- Parent.vue -->
<template>
  <ChildButton @increment="onIncrement" @delete="remove" />
</template>

<script setup>
function onIncrement(amount) {
  console.log('incremented by', amount)
}
</script>

v-model on Components

defineModel() is the modern (Vue 3.4+) macro for two-way binding on components — it returns a ref that auto-syncs with the parent's v-model. For multiple bindings, use named models: v-model:fieldName. Under the hood this emits 'update:modelValue' (or 'update:fieldName') and the parent syncs the bound ref. Older code uses defineProps + defineEmits manually.

vue3
<!-- CustomInput.vue -->
<script setup>
const model = defineModel()
</script>

<template>
  <input :value="model" @input="model = $event.target.value" />
</template>

<!-- Parent.vue -->
<template>
  <CustomInput v-model="text" />
</template>

<!-- multiple v-models (Vue 3.4+) -->
<template>
  <UserForm v-model:name="name" v-model:email="email" />
</template>
09

Form Bindings

Text & Textarea

v-model on text inputs syncs on every input event. For textarea, put v-model directly on the element (not as :value — multiline content in attribute won't work). Modifiers: .lazy syncs on blur/change (less updates), .trim strips whitespace, .number casts the value to a Number. Chain modifiers: v-model.lazy.trim.

vue3
<script setup>
import { ref } from 'vue'
const text = ref('')
const message = ref('')
</script>

<template>
  <!-- text input: binds on every keystroke -->
  <input v-model="text" placeholder="Type here" />
  <p>You typed: {{ text }}</p>

  <!-- textarea: insert tags inside, not :value -->
  <textarea v-model="message" rows="4"></textarea>
  <p>Preview: {{ message }}</p>

  <!-- .lazy: sync on 'change' (blur) instead of 'input' -->
  <input v-model.lazy="text" />

  <!-- .trim: strip leading/trailing whitespace -->
  <input v-model.trim="text" />

  <!-- .number: cast to Number (NaN if invalid) -->
  <input v-model.number="age" type="number" />
</template>

Checkbox & Radio

A single checkbox binds to a boolean (checked = true). Multiple checkboxes sharing the same v-model bind to an array of their value attributes — checking adds the value, unchecking removes it. Radio buttons sharing a v-model bind the chosen option's value to the ref. Use true-value / false-value attributes to customize the boolean mapping.

vue3
<script setup>
import { ref } from 'vue'
const agreed = ref(false)
const fruits = ref([])     // array for multi-checkbox
const picked = ref('')     // string for radio
</script>

<template>
  <!-- single checkbox -> boolean -->
  <input type="checkbox" v-model="agreed" /> I agree

  <!-- multiple checkboxes with same v-model -> array -->
  <input type="checkbox" value="apple" v-model="fruits" /> Apple
  <input type="checkbox" value="banana" v-model="fruits" /> Banana
  <input type="checkbox" value="cherry" v-model="fruits" /> Cherry
  <p>Selected: {{ fruits }}</p>

  <!-- radio: value binds to the chosen option -->
  <input type="radio" value="A" v-model="picked" /> A
  <input type="radio" value="B" v-model="picked" /> B
</template>

Select

On <select>, v-model goes on the select element (not the options) and syncs with the chosen option's value. Use a disabled placeholder option with empty value for 'please select' UX. For dynamic options, render them with v-for and bind :value. Add the multiple attribute to allow multi-selection — v-model then binds to an array of selected values.

vue3
<script setup>
import { ref } from 'vue'
const selected = ref('')
const multi = ref([])

const options = [
  { value: 'a', label: 'Option A' },
  { value: 'b', label: 'Option B' }
]
</script>

<template>
  <!-- single select -->
  <select v-model="selected">
    <option disabled value="">Please select</option>
    <option value="a">A</option>
    <option value="b">B</option>
  </select>

  <!-- dynamic options -->
  <select v-model="selected">
    <option v-for="opt in options" :key="opt.value" :value="opt.value">
      {{ opt.label }}
    </option>
  </select>

  <!-- multi-select (hold Ctrl/Cmd) -> array -->
  <select v-model="multi" multiple>
    <option value="x">X</option>
    <option value="y">Y</option>
  </select>
</template>

Form Submission & Validation

Use @submit.prevent to handle form submission while preventing the default page reload. Reactive form objects group related fields. For validation, libraries like VeeValidate or Zod + a composable are popular for complex forms, but a simple validate() function returning an errors object works well for small forms. Always prevent default to enable SPA-style submissions.

vue3
<script setup>
import { reactive, ref } from 'vue'

const form = reactive({
  email: '',
  password: ''
})
const errors = ref({})

function validate() {
  errors.value = {}
  if (!form.email.includes('@')) errors.value.email = 'Invalid email'
  if (form.password.length < 6) errors.value.password = 'Too short'
  return Object.keys(errors.value).length === 0
}

function onSubmit() {
  if (!validate()) return
  // submit form...
}
</script>

<template>
  <form @submit.prevent="onSubmit">
    <input v-model.trim="form.email" />
    <span v-if="errors.email">{{ errors.email }}</span>

    <input type="password" v-model="form.password" />
    <span v-if="errors.password">{{ errors.password }}</span>

    <button type="submit">Submit</button>
  </form>
</template>

v-model with Custom Components

Wrap form fields in custom components for reuse. defineModel() returns a ref that reads the parent's value and writes back via 'update:modelValue' emit. The component binds :value to the model and @input updates it. This pattern lets you build a consistent design-system of form inputs (BaseInput, BaseSelect, BaseCheckbox) usable with v-model just like native elements.

vue3
<!-- BaseInput.vue -->
<script setup>
const model = defineModel()

defineProps({
  label: String,
  type: { type: String, default: 'text' }
})
</script>

<template>
  <label>
    {{ label }}
    <input
      :type="type"
      :value="model"
      @input="model = $event.target.value"
    />
  </label>
</template>

<!-- Usage -->
<template>
  <BaseInput v-model="email" label="Email" type="email" />
</template>
10

Components

Defining & Using Components

A component is a .vue file with template, script, and optional style. Import it (auto-registered locally in <script setup>) and use it as a custom HTML tag. Component names are PascalCase in script and can be kebab-case (my-button) in templates — Vue normalizes them. Globally-registered components (app.component) are available everywhere without import.

vue3
<!-- MyButton.vue -->
<template>
  <button class="btn"><slot /></button>
</template>

<!-- App.vue -->
<script setup>
import MyButton from './MyButton.vue'
</script>

<template>
  <MyButton>Click me</MyButton>
</template>

Dynamic Components

<component :is='...'> renders a component dynamically — the value can be a registered name string, an imported component object, or a defineAsyncComponent. Wrap it in <keep-alive> to preserve component state (form input, scroll position) when toggling between tabs. Use shallowRef (not ref) for component objects to avoid deep reactivity overhead.

vue3
<script setup>
import { ref, shallowRef } from 'vue'
import HomeTab from './HomeTab.vue'
import PostsTab from './PostsTab.vue'
import ArchiveTab from './ArchiveTab.vue'

const tabs = { HomeTab, PostsTab, ArchiveTab }
const current = shallowRef('HomeTab')

// keep component state when switching (e.g. form input)
import { keep-alive } from 'vue' // not real — use the tag
</script>

<template>
  <button v-for="(comp, name) in tabs" :key="name" @click="current = name">
    {{ name }}
  </button>

  <keep-alive>
    <component :is="tabs[current]" />
  </keep-alive>
</template>

Async Components

defineAsyncComponent lazy-loads a component on first render — the chunk is fetched on demand, reducing initial bundle size. Configure loading/error states for a smooth UX. Combine with Suspense for coordinating multiple async components. The loader returns a Promise of the component module (Vite/Webpack auto-splits based on the dynamic import()).

vue3
<script setup>
import { defineAsyncComponent } from 'vue'

// lazy-load on first use (code-split)
const HeavyChart = defineAsyncComponent(() =>
  import('./HeavyChart.vue')
)

// with loading, error, delay, timeout states
const AdminPanel = defineAsyncComponent({
  loader: () => import('./AdminPanel.vue'),
  loadingComponent: LoadingSpinner,
  errorComponent: ErrorMsg,
  delay: 200,        // show loading after 200ms
  timeout: 5000      // error if not loaded in 5s
})
</script>

<template>
  <HeavyChart />
  <AdminPanel />
</template>

Component v-model (Multiple)

Named models let a single component expose multiple two-way bindings. defineModel('name') declares each one. The parent uses v-model:name='...'. This is great for form components that bundle several fields (e.g. an address component with street, city, zip). The default v-model (no name) maps to 'modelValue'.

vue3
<!-- UserForm.vue -->
<script setup>
const firstName = defineModel('firstName')
const lastName = defineModel('lastName')
</script>

<template>
  <input v-model="firstName" />
  <input v-model="lastName" />
</template>

<!-- Parent.vue -->
<template>
  <UserForm
    v-model:firstName="form.first"
    v-model:lastName="form.last"
  />
</template>

Recursive & Circular Components

A component can reference itself by its filename (auto-self-reference). This is perfect for tree structures (file trees, comment threads, menus). For circular references between two components (A imports B, B imports A), use defineAsyncComponent for one of them, or import in beforeCreate. Vue handles recursive components but watch for infinite loops — always have a base case (no children).

vue3
<!-- TreeNode.vue -->
<script setup>
defineProps({
  node: Object
})

// a component can reference itself by its filename
// TreeNode.vue can use <TreeNode /> recursively
</script>

<template>
  <div>
    {{ node.label }}
    <ul v-if="node.children?.length">
      <li v-for="child in node.children" :key="child.id">
        <TreeNode :node="child" />
      </li>
    </ul>
  </div>
</template>
11

Props

Declaring Props

defineProps declares a component's input contract. Runtime declaration supports type, default, required, and validator. Vue 3 type-based declaration (TS only) gives type safety but loses runtime validation (use with volar + a validator lib). Defaults for objects/arrays must use a factory function: default: () => [].

vue3
<script setup>
// runtime declaration (validates types)
const props = defineProps({
  title: String,
  count: { type: Number, default: 0 },
  items: { type: Array, required: true },
  callback: { type: Function, default: () => {} },
  status: {
    type: String,
    validator: (v) => ['active', 'idle'].includes(v)
  }
})

// type-based declaration (TS only, no runtime validation)
const props2 = defineProps<{
  title: string
  count?: number
}>()
</script>

Prop Naming & Passing

Props should be camelCase in JS (greetingMessage) and kebab-case in templates (greeting-message) — HTML is case-insensitive so kebab-case is safer. Vue auto-converts between the two. Static string props use plain attribute syntax; dynamic values use :prop. Pass numbers/booleans/objects always with : to avoid them being treated as strings.

vue3
<!-- Child.vue -->
<script setup>
defineProps({
  greetingMessage: String,
  userId: Number
})
</script>

<!-- Parent.vue -->
<template>
  <!-- kebab-case in templates (HTML-friendly) -->
  <Child greeting-message="Hello" :user-id="42" />

  <!-- camelCase also works in SFC templates -->
  <Child greetingMessage="Hello" :userId="42" />
</template>

One-Way Data Flow

Props are read-only — never mutate them directly. Vue warns in dev mode. To change a prop's value, emit an event and let the parent update it (one-way data flow). For a local working copy, initialize a ref from the prop and sync via watch. The cleanest two-way pattern is v-model with defineModel, which abstracts the emit-and-update dance.

vue3
<!-- BAD: mutating a prop -->
<script setup>
const props = defineProps(['count'])

function inc() {
  props.count++  // WARNING: don't mutate props!
}
</script>

<!-- GOOD: emit to parent, or use local copy -->
<script setup>
import { ref, watch } from 'vue'
const props = defineProps(['count'])
const emit = defineEmits(['update:count'])

// local copy that syncs with prop
const local = ref(props.count)
watch(() => props.count, v => local.value = v)

function inc() {
  local.value++
  emit('update:count', local.value)
}
</script>

Prop Validation & Defaults

Vue validates props in dev mode and warns on failure (not in production). type accepts a constructor (String, Number, Boolean, Array, Object, Function, Symbol) or an array of them. Object and Array defaults MUST be functions returning a new instance — otherwise all components would share the same default reference (mutation bugs). Validators run after type checks.

vue3
<script setup>
const props = defineProps({
  // type can be an array of allowed types
  value: { type: [String, Number], required: true },

  // object/array default MUST be a factory function
  list: {
    type: Array,
    default: () => []
  },

  // custom validator
  age: {
    type: Number,
    validator: (v) => v >= 0 && v <= 150
  },

  // multiple checks combined
  email: {
    type: String,
    required: true,
    validator: (v) => v.includes('@')
  }
})
</script>

Boolean Casting & Attributes Fallthrough

Boolean props have special casting: presence without value means true. Attribute fallthrough passes non-prop attributes (class, style, id, data-*) to the root element automatically. Multiple roots require explicit v-bind='$attrs' on the desired element. Disable fallthrough with defineOptions({ inheritAttrs: false }) for full control.

vue3
<!-- prop declared as Boolean -->
<script setup>
defineProps({
  disabled: Boolean,
  active: Boolean
})
</script>

<!-- Parent -->
<template>
  <MyButton disabled />       <!-- disabled = true -->
  <MyButton :disabled="false" /> <!-- disabled = false -->
  <MyButton />                <!-- disabled = undefined -->
</template>

<!-- Attribute fallthrough: non-prop attributes
     land on the root element automatically -->
<template>
  <!-- <MyButton data-id="5" class="large" /> -->
  <!-- the data-id and class merge onto the <button> root -->
  <button>...</button>
</template>
12

Emits

Declaring & Emitting

defineEmits declares events a component can emit. Object form allows validators that receive the payload and return false to warn (dev mode). Emits go up the tree — parents listen with @event-name. Always declare emits explicitly: it documents the API, enables attr fallthrough exclusion, and supports validation. emit() takes the event name plus optional payload args.

vue3
<script setup>
// declare emits (with optional validation)
const emit = defineEmits({
  // null-ish = no validation
  click: null,

  // function = validate payload
  submit: (payload) => {
    if (!payload.email) {
      console.warn('submit needs email')
      return false
    }
    return true
  }
})

function handleSubmit() {
  emit('submit', { email: '[email protected]', name: 'Alice' })
}
</script>

Listening to Emits

Parents listen with @event-name (or v-on:event-name). The handler receives any payload emitted. Inline arrow functions are handy for simple transformations (@increment='n => count += n'). Method handlers auto-receive the payload as the first argument. Native DOM events on a component's root element fall through unless declared in emits — declaring 'click' makes it a custom event instead.

vue3
<!-- Child.vue -->
<script setup>
const emit = defineEmits(['increment', 'delete'])

function click() {
  emit('increment', 5)
}
</script>

<template>
  <button @click="click">+5</button>
</template>

<!-- Parent.vue -->
<template>
  <!-- inline handler -->
  <Child @increment="n => count += n" />

  <!-- method handler -->
  <Child @increment="onIncrement" @delete="onDelete" />
</template>

<script setup>
function onIncrement(amount) {
  console.log('incremented by', amount)
}
</script>

v-model Implementation

v-model on a component is sugar for :modelValue + @update:modelValue. The child receives the value via a modelValue prop and emits update:modelValue to sync. This is the manual pattern; defineModel() (Vue 3.4+) wraps it. For named v-model (v-model:foo), use prop name foo and event update:foo. This enables building form components that work like native inputs.

vue3
<!-- CustomInput.vue (manual implementation) -->
<script setup>
const props = defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue'])

function onInput(e) {
  emit('update:modelValue', e.target.value)
}
</script>

<template>
  <input
    :value="modelValue"
    @input="onInput"
  />
</template>

<!-- Parent -->
<template>
  <CustomInput v-model="text" />
</template>

Emit Validation

Emit validators receive the payload args and return false to trigger a dev-mode warning. They don't prevent the emit — they just warn. This catches contract violations early in development. Use them for documenting expected payload shapes. In production, validators are stripped, so don't rely on them for security or control flow.

vue3
<script setup>
const emit = defineEmits({
  // no validation
  click: null,

  // validate payload; return false to warn
  change: (value) => {
    if (typeof value !== 'string') {
      console.warn('change payload must be string')
      return false
    }
    return true
  },

  // validate multiple args
  update: (id, value) => {
    if (!id || value === undefined) return false
    return true
  }
})

// validation runs on emit
emit('change', 123)  // warns: must be string
</script>

Events vs Props: When to Use

Follow the one-way data flow: props down, events up. For two-way binding, use v-model (props + emit sugar). For deeply nested communication, props-drilling becomes painful — use provide/inject for dependency injection or a store (Pinia) for shared state. Events are for 'something happened' notifications; props are for 'here's your data'.

vue3
<!-- Parent-to-child: props (down) -->
<template>
  <Child :data="parentData" :config="config" />
</template>

<!-- Child-to-parent: emits (up) -->
<template>
  <Child @change="onChange" @submit="onSubmit" />
</template>

<!-- Two-way: v-model (combines both) -->
<template>
  <Child v-model="value" />
</template>

<!-- For complex state, prefer provide/inject or a store -->
13

Slots

Default Slot

Slots let a parent inject content into a child component's template. The <slot> element is the placeholder. The parent's child content fills the default slot. Slots enable composition: Card handles the wrapper styling, the parent controls the inner content. Without slots, you'd need a prop for every possible inner element — slots are far more flexible.

vue3
<!-- Card.vue -->
<template>
  <div class="card">
    <slot />
  </div>
</template>

<!-- Usage -->
<template>
  <Card>
    <p>This goes into the default slot</p>
    <button>Action</button>
  </Card>
</template>

Named Slots

Named slots let a component have multiple content placeholders. Declare with <slot name='foo' />, fill with <template #foo>. The # is shorthand for v-slot:. The unnamed slot is the 'default' slot — content without a template wrapper goes there. Named slots are perfect for layouts (header, sidebar, main, footer) where the parent fills each region.

vue3
<!-- Layout.vue -->
<template>
  <header><slot name="header" /></header>
  <main><slot /></main>
  <footer><slot name="footer" /></footer>
</template>

<!-- Usage with # shorthand -->
<template>
  <Layout>
    <template #header>
      <h1>My Page Title</h1>
    </template>

    <!-- default slot (no #name) -->
    <p>Main content here</p>

    <template #footer>
      <p>© 2025</p>
    </template>
  </Layout>
</template>

Slot Props (Scoped Slots)

Scoped slots let a child pass data back up to the parent's slot content. The child binds data on <slot :item='item'>; the parent receives it via v-slot='{ item }' (or #default='{ item }'). This is the foundation of reusable list components (data table, virtual list): the child handles iteration, the parent controls rendering of each item.

vue3
<!-- List.vue -->
<template>
  <ul>
    <li v-for="item in items" :key="item.id">
      <slot :item="item" :index="index" />
    </li>
  </ul>
</template>

<script setup>
defineProps(['items'])
</script>

<!-- Usage: receive slot props via v-slot -->
<template>
  <List :items="todos">
    <template #default="{ item, index }">
      <input type="checkbox" v-model="item.done" />
      <span :class="{ done: item.done }">{{ index }}: {{ item.text }}</span>
    </template>
  </List>
</template>

Default Slot Content

Slot fallback content goes between <slot> and </slot>. It renders only when the parent provides no slot content. This is great for sensible defaults — a button label, an empty-state message, a placeholder. If the parent passes even an empty string, the fallback is overridden.

vue3
<!-- Button.vue -->
<template>
  <button>
    <slot>Click here</slot>  <!-- fallback content -->
  </button>
</template>

<!-- Usage -->
<template>
  <Button />             <!-- renders "Click here" -->
  <Button>Save</Button>  <!-- renders "Save" -->
</template>

Render Scope & Use Slots

Slot content is rendered in the PARENT's scope — it can access parent state but not child state directly (unless via slot props). This separation lets a child handle mechanics (pagination, virtualization, fetching) while the parent controls presentation. The scoped slot pattern (FancyList + UserCard) is a powerful composition technique — the child owns the data flow, the parent owns the rendering.

vue3
<!-- FancyList.vue (scoped slot pattern) -->
<script setup>
const props = defineProps(['items', 'pageSize'])
import { ref, computed } from 'vue'

const page = ref(1)
const paged = computed(() =>
  props.items.slice((page.value - 1) * props.pageSize, page.value * props.pageSize)
)
</script>

<template>
  <div v-for="item in paged" :key="item.id">
    <slot :item="item" :page="page" />
  </div>
</template>

<!-- Usage: parent fully controls rendering -->
<FancyList :items="users" :pageSize="10">
  <template #default="{ item, page }">
    <UserCard :user="item" />
    <small>Page {{ page }}</small>
  </template>
</FancyList>
14

Lifecycle Hooks

Composition API Hooks

Lifecycle hooks run at specific stages. onMounted fires after the DOM is rendered (safe to access elements, run canvas libs, fetch data). onUpdated fires after every reactive update — avoid mutating state here (infinite loop). onUnmounted is for cleanup (timers, listeners, subscriptions). Hooks must be called synchronously in setup, but can be called multiple times.

vue3
<script setup>
import {
  onBeforeMount, onMounted,
  onBeforeUpdate, onUpdated,
  onBeforeUnmount, onUnmounted,
  onActivated, onDeactivated,
  onErrorCaptured
} from 'vue'

onBeforeMount(() => console.log('before mount'))
onMounted(() => console.log('mounted — DOM ready'))
onBeforeUpdate(() => console.log('before re-render'))
onUpdated(() => console.log('after re-render'))
onBeforeUnmount(() => console.log('before unmount'))
onUnmounted(() => console.log('unmounted — cleanup'))
</script>

Setup vs Created

In <script setup>, the entire script body runs during component initialization — equivalent to beforeCreate + created in Options API. There's no separate created() hook. Top-level code (declarations, function calls, even async work) runs once per instance. For data fetching, this is a common place to start (though onMounted is also fine — fetch starts the same either way).

vue3
<script setup>
// <script setup> runs BEFORE the component is mounted
// (equivalent to beforeCreate + created in Options API)

import { ref } from 'vue'

// this code runs once per component instance,
// before reactive state is exposed to the template
const count = ref(0)
console.log('component created, count =', count.value)

// no need for created() hook — just run code here
fetch('/api/data')
  .then(r => r.json())
  .then(data => { /* ... */ })
</script>

Mounted & Cleanup

Pairs of setup/cleanup are essential: every listener, timer, subscription, or observer added in onMounted MUST be removed in onUnmounted. Failing to clean up causes memory leaks and zombie handlers firing on destroyed components. The pattern: addEventListener in onMounted, removeEventListener in onUnmounted. For watchers, use the returned stop function or they auto-clean on unmount.

vue3
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'

const windowWidth = ref(window.innerWidth)

function onResize() {
  windowWidth.value = window.innerWidth
}

onMounted(() => {
  // setup side effects after DOM is ready
  window.addEventListener('resize', onResize)
  console.log('mounted')
})

onUnmounted(() => {
  // ALWAYS clean up to avoid memory leaks
  window.removeEventListener('resize', onResize)
})
</script>

KeepAlive Hooks

When a component is wrapped in <KeepAlive>, switching away doesn't unmount it — it's deactivated (cached). onActivated and onDeactivated fire on show/hide. Use them to pause/resume work (polling, video playback) without full setup/teardown. onMounted still fires once (first activation); onUnmounted never fires while cached. These hooks let you optimize resource use for cached tabs.

vue3
<script setup>
import { onActivated, onDeactivated } from 'vue'

// these fire when the component is wrapped in <KeepAlive>
// and is being shown (activated) or hidden (deactivated)
onActivated(() => {
  console.log('component shown again — resume polling')
  startPolling()
})

onDeactivated(() => {
  console.log('component hidden but not unmounted — pause')
  stopPolling()
})

// onMounted fires only once (first show)
// onActivated fires every time it's re-shown
</script>

Error Captured

onErrorCaptured catches errors from descendant components (in templates, render, lifecycle, watchers). It receives (error, instance, info). Return false to stop the error from propagating to parent errorCaptured handlers and the global errorHandler. Use it for boundary components that gracefully handle child errors (like React Error Boundaries). Combine with Suspense for async error states.

vue3
<script setup>
import { onErrorCaptured, ref } from 'vue'

const error = ref(null)

onErrorCaptured((err, instance, info) => {
  // err: the error thrown
  // instance: component that threw
  // info: lifecycle hook or event name where it occurred
  error.value = err.message
  console.error('Captured:', err, info)

  // return false to stop propagation to parent
  return false
})
</script>

<template>
  <div v-if="error" class="error">{{ error }}</div>
  <slot v-else />
</template>
15

Composition API

ref vs reactive

ref() works with any value (primitives, objects). Reactive access requires .value in JS, but auto-unwraps in templates. reactive() only works on objects/arrays — no .value needed. Use ref for primitives and replaceable values; use reactive for grouped state. Rule of thumb: prefer ref() for individual values, reactive() for state objects. Mixing is fine — refs auto-unwrap inside reactive objects.

vue3
<script setup>
import { ref, reactive } from 'vue'

// ref: works with any value, access via .value
const count = ref(0)
count.value++
console.log(count.value)  // 1

// reactive: only for objects/arrays, no .value needed
const state = reactive({ count: 0, user: { name: 'A' } })
state.count++
console.log(state.count)  // 1

// refs auto-unwrap in templates and reactive objects
const wrapper = reactive({ count })  // wrapper.count === count.value
</script>

reactive Pitfalls

reactive objects have two pitfalls: you can't reassign the variable (state = newObj) — mutate properties instead (state.list = []). Destructuring loses reactivity because the destructured variable is a snapshot. Use toRefs() to convert reactive properties to refs that stay linked. These pitfalls are why many devs prefer ref() — it's replaceable (just set .value) and destructuring-friendly.

vue3
<script setup>
import { reactive, ref } from 'vue'

const state = reactive({ list: [1, 2, 3] })

// PITFALL 1: replacing the whole object loses reactivity
// state = reactive({ list: [4, 5] })  // ERROR: reassign

// GOOD: mutate properties
state.list = [4, 5]

// PITFALL 2: destructuring loses reactivity
let { list } = state  // 'list' is now plain
// list.push(4) won't trigger updates

// FIX: use toRefs to keep reactivity
import { toRefs } from 'vue'
const { list } = toRefs(state)  // now list is a ref
</script>

toRef & toRefs

toRef() converts a single reactive property to a ref that stays linked (mutating either affects the other). toRefs() converts ALL properties — useful for returning reactive state from composables while preserving destructuring. Without toRefs, destructured values lose reactivity. Pattern: composable returns toRefs(state) so consumers can destructure without breaking reactivity.

vue3
<script setup>
import { reactive, toRef, toRefs } from 'vue'

const state = reactive({
  count: 0,
  name: 'Alice'
})

// toRef: single property -> ref (stays linked to state)
const countRef = toRef(state, 'count')

// toRefs: all properties -> object of refs
const { count, name } = toRefs(state)

countRef.value++      // updates state.count
count.value++         // also updates state.count
console.log(state.count)  // 2
</script>

computed & watch in Setup

In <script setup>, computed() and watch() are imported and called directly. computed creates a derived ref; watch runs a callback on source change. watch can take a single ref, a getter, an array of sources, or a reactive object. These are the core Composition API primitives — combine them to encapsulate logic in composables.

vue3
<script setup>
import { ref, computed, watch } from 'vue'

const a = ref(1)
const b = ref(2)

// computed: derived value, cached
const sum = computed(() => a.value + b.value)

// watch: side effect on change
watch(sum, (newSum) => {
  console.log('sum changed to', newSum)
})

// watch multiple sources
watch([a, b], ([newA, newB]) => {
  console.log('a or b changed:', newA, newB)
})
</script>

shallowRef & shallowReactive

shallowRef and shallowReactive opt out of deep reactivity — only the top-level reference/properties are tracked. This dramatically improves performance for large data structures (e.g. a chart's data array, a 3D scene graph) where deep tracking is wasteful. Use triggerRef() to manually notify Vue after a deep mutation. Reach for these only when you've measured a perf issue.

vue3
<script setup>
import { shallowRef, shallowReactive, triggerRef } from 'vue'

// shallowRef: only .value changes trigger updates
// (deep changes to .value properties do NOT)
const big = shallowRef({ items: [1, 2, 3] })
big.value.items.push(4)       // NO update triggered
big.value = { items: [1,2,3,4] }  // update triggered

// force update after deep mutation
big.value.items.push(5)
triggerRef(big)  // tells Vue to re-render

// shallowReactive: only root-level props are reactive
const state = shallowReactive({ user: { name: 'A' } })
state.count = 1            // reactive
state.user.name = 'B'      // NOT reactive
</script>
16

Composables

Writing a Composable

A composable is a function that encapsulates reusable reactive logic. By convention, name it useSomething. It uses Composition API hooks (onMounted, etc.) which bind to the consuming component's lifecycle. Return refs and methods. The component destructures them. Composables are the Vue equivalent of React hooks — they let you share stateful logic without render props or HOCs.

vue3
<!-- useMousePosition.js -->
import { ref, onMounted, onUnmounted } from 'vue'

export function useMousePosition() {
  const x = ref(0)
  const y = ref(0)

  function update(e) {
    x.value = e.pageX
    y.value = e.pageY
  }

  onMounted(() => window.addEventListener('mousemove', update))
  onUnmounted(() => window.removeEventListener('mousemove', update))

  return { x, y }
}

<!-- In component -->
<script setup>
import { useMousePosition } from './useMousePosition'
const { x, y } = useMousePosition()
</script>

Stateful Composable (Shared State)

When state is declared at module scope (outside the function), it's a singleton — all components using the composable share the same state. This is a lightweight alternative to Pinia for simple global state. Use readonly() to expose state without allowing direct mutation — components must call login/logout to change it. For complex apps, prefer Pinia (better devtools, persistence, SSR support).

vue3
<!-- useUser.js -->
import { reactive, readonly } from 'vue'

// module-scoped state — shared across all consumers
const state = reactive({
  user: null,
  loading: false
})

export function useUser() {
  async function login(credentials) {
    state.loading = true
    state.user = await fetch('/api/login', {
      method: 'POST',
      body: JSON.stringify(credentials)
    }).then(r => r.json())
    state.loading = false
  }

  function logout() {
    state.user = null
  }

  return {
    user: readonly(state.user),  // expose read-only
    loading: readonly(state.loading),
    login,
    logout
  }
}

useFetch Composable

This composable wraps fetch with loading/error state and re-fetches when a reactive url changes. isRef() checks if the input is a ref; unref() unwraps it (returns the value for refs, identity otherwise). Returning refresh lets the consumer manually re-fetch. This pattern (reactive input + auto re-run + expose state) is the bread and butter of composables for data fetching.

vue3
<!-- useFetch.js -->
import { ref, watchEffect, isRef, unref } from 'vue'

export function useFetch(url) {
  const data = ref(null)
  const error = ref(null)
  const loading = ref(true)

  async function doFetch() {
    loading.value = true
    error.value = null
    try {
      const res = await fetch(unref(url))
      if (!res.ok) throw new Error('HTTP ' + res.status)
      data.value = await res.json()
    } catch (e) {
      error.value = e.message
    } finally {
      loading.value = false
    }
  }

  // re-fetch when url ref changes
  if (isRef(url)) {
    watchEffect(doFetch)
  } else {
    doFetch()
  }

  return { data, error, loading, refresh: doFetch }
}

Composable with Cleanup

Composables that register side effects (listeners, timers, subscriptions) MUST clean up — typically via onUnmounted inside the composable. This binds the cleanup to whatever component consumes the composable, so the consumer doesn't have to remember. This is a major advantage of composables over utility functions: they participate in the component lifecycle.

vue3
<!-- useEventListener.js -->
import { onUnmounted } from 'vue'

export function useEventListener(target, event, callback) {
  target.addEventListener(event, callback)

  // auto-cleanup when the consuming component unmounts
  onUnmounted(() => {
    target.removeEventListener(event, callback)
  })
}

<!-- Usage -->
<script setup>
import { useEventListener } from './useEventListener'
import { ref } from 'vue'

const key = ref('')
useEventListener(window, 'keydown', (e) => {
  key.value = e.key
})
// listener is removed automatically on unmount
</script>

Composable Conventions

Follow these conventions: (1) name composables with use prefix; (2) return refs (not plain values — plain values lose reactivity); (3) accept refs or getters as inputs for flexibility; (4) use onUnmounted for cleanup; (5) keep them focused (one concern per composable). These conventions make composables composable with each other and predictable to use.

vue3
<!-- DO: name with 'use' prefix, return refs -->
export function useCounter(initial = 0) {
  const count = ref(initial)
  const inc = () => count.value++
  const dec = () => count.value--
  return { count, inc, dec }
}

<!-- AVOID: returning plain values (loses reactivity) -->
export function badCounter() {
  let count = 0  // plain variable — NOT reactive!
  return { count, inc: () => count++ }
}

<!-- DO: accept refs/getters for flexible inputs -->
export function useSearch(query) {
  // query can be a ref, a getter, or a plain value
  return watchEffect(() => fetch('/api?q=' + unref(query)))
}
17

Reactivity Deep Dive

Reactive Proxy Mechanics

Vue 3 reactivity uses Proxy (ES6). reactive() returns a Proxy that intercepts get (to track dependencies) and set (to trigger updates). The original object is not modified — you get a wrapped version. isReactive() checks if something is a reactive proxy. This is more powerful than Vue 2's Object.defineProperty approach, which couldn't detect new property additions.

vue3
<script setup>
import { reactive, isReactive, isProxy } from 'vue'

const state = reactive({ count: 0 })

// reactive() wraps the object in a Proxy
// reads/writes are tracked and trigger updates
state.count++  // triggers re-render

// the original object is NOT reactive
const raw = { count: 0 }
const wrapped = reactive(raw)
console.log(raw === wrapped)         // false
console.log(isReactive(wrapped))     // true
console.log(isReactive(raw))         // false
console.log(isProxy(wrapped))        // true
</script>

ref Unwrapping Rules

ref unwrapping rules: in <script>, access via .value; in templates and inside reactive objects, refs auto-unwrap to their .value. This is why {{ count }} in a template works without .value. The auto-unwrap is shallow — nested refs in arrays/Maps don't unwrap. Reactive objects that contain refs mutate the ref's value when assigned: state.count = 5 sets count.value = 5.

vue3
<script setup>
import { ref, reactive } from 'vue'

const count = ref(0)
const state = reactive({ count })

// in JS: ref needs .value
count.value = 1

// in reactive object: ref auto-unwraps
console.log(state.count)  // 1 (not count.value)
state.count = 5           // updates count.value

// in templates: ref auto-unwraps
// {{ count }} renders count.value
</script>

<template>
  <p>{{ count }}</p>  <!-- 5, not [object Object] -->
</template>

Effect & track/trigger (Internals)

effect() is the low-level primitive behind reactivity — it runs a function, tracks every reactive read inside it, and re-runs when any tracked value changes. computed, watch, watchEffect, and component rendering all build on effect. You rarely call effect() directly in app code — prefer computed/watch/watchEffect — but it's the engine. Understanding it helps reasoning about reordering and timing.

vue3
<script setup>
import { ref, effect } from 'vue'

const count = ref(0)

// effect runs immediately, then re-runs on dep change
effect(() => {
  console.log('count is', count.value)
})
// logs: count is 0

count.value = 10
// logs: count is 10

// internally:
// - effect tracks dependencies accessed inside it
// - setting count.value triggers all tracked effects
</script>

markRaw & readonly

markRaw() permanently opts an object out of reactivity — useful for third-party class instances (charts, maps, game state) that have their own update mechanisms and shouldn't be wrapped in a Proxy. readonly() creates a read-only view of a reactive object — attempts to mutate warn in dev mode. Use readonly to expose state from composables/stores while preventing direct mutation.

vue3
<script setup>
import { reactive, markRaw, readonly, isReadonly } from 'vue'

// markRaw: opt a value OUT of reactivity forever
const bigChart = markRaw(new HeavyChartLib())
const state = reactive({ chart: bigChart })
// state.chart mutations won't trigger Vue updates
// (use chart's own API to update it)

// readonly: make a reactive object read-only
const original = reactive({ count: 0 })
const frozen = readonly(original)
frozen.count = 5
// warning: target is readonly (dev mode)

console.log(isReadonly(frozen))  // true
</script>

Custom Ref (factory)

customRef() lets you control dependency tracking and triggering explicitly. The factory receives track() and trigger() — call track() in get() to register the dependency, call trigger() in set() to notify updates. This enables patterns like debounced refs, lazy refs, or refs backed by storage. Powerful but use sparingly — usually watchEffect + a normal ref is clearer.

vue3
<script setup>
import { customRef } from 'vue'

// debounce a ref's updates
function debouncedRef(value, delay = 200) {
  let timeout
  return customRef((track, trigger) => ({
    get() {
      track()  // tell Vue this value is a dependency
      return value
    },
    set(newValue) {
      clearTimeout(timeout)
      timeout = setTimeout(() => {
        value = newValue
        trigger()  // notify Vue of the change
      }, delay)
    }
  }))
}

const text = debouncedRef('', 300)
// input only triggers re-render 300ms after typing stops
</script>
18

Vue Router

Router Setup

createRouter sets up routing. createWebHistory uses HTML5 history mode (clean URLs, requires server fallback to index.html). Lazy-load route components with dynamic import() for code-splitting — each route becomes its own bundle. Dynamic segments (:id) are accessed via route.params. Install the router as a plugin with app.use(router).

vue3
<!-- router/index.js -->
import { createRouter, createWebHistory } from 'vue-router'
import Home from '../views/Home.vue'

const routes = [
  { path: '/', name: 'home', component: Home },
  {
    path: '/about',
    name: 'about',
    // lazy-load for code-splitting
    component: () => import('../views/About.vue')
  },
  { path: '/user/:id', name: 'user', component: () => import('../views/User.vue') }
]

export const router = createRouter({
  history: createWebHistory(),
  routes
})

<!-- main.js -->
import { createApp } from 'vue'
import App from './App.vue'
import { router } from './router'

createApp(App).use(router).mount('#app')

RouterView & RouterLink

<RouterView> is the placeholder where the matched route's component renders. <RouterLink> renders an <a> tag but intercepts clicks for client-side navigation (no full page reload). The to prop accepts a string path or an object ({ name, params, query }). RouterLink adds active/exact-active classes for styling the current link.

vue3
<!-- App.vue -->
<template>
  <nav>
    <!-- RouterLink renders <a>, handles navigation -->
    <RouterLink to="/">Home</RouterLink>
    <RouterLink to="/about">About</RouterLink>
    <RouterLink :to="{ name: 'user', params: { id: 42 } }">
      User 42
    </RouterLink>
  </nav>

  <!-- matched route's component renders here -->
  <RouterView />
</template>

Navigation & Programmatic Routing

useRouter() gives the router instance for navigation; useRoute() gives the current route (reactive). push adds a history entry (back button works); replace doesn't. Route properties (path, params, query, hash, name, matched) are reactive — watchers and computed re-run on navigation. Always use router.push for in-app navigation — never window.location to keep it SPA.

vue3
<script setup>
import { useRouter, useRoute } from 'vue-router'

const router = useRouter()
const route = useRoute()

// push to a new URL (adds to history)
function goHome() {
  router.push('/')
  router.push({ name: 'user', params: { id: 1 } })
  router.push({ path: '/search', query: { q: 'vue' } })
}

// replace current entry (no back button)
router.replace('/login')

// go back/forward
router.back()
router.forward()
router.go(-2)

// access current route info
console.log(route.path, route.params.id, route.query.q)
</script>

Route Guards

Guards control navigation flow. Global beforeEach is great for auth checks. Per-route beforeEnter guards specific routes. In-component guards (onBeforeRouteLeave, onBeforeRouteUpdate) are useful for unsaved-changes warnings. Return false or a route to cancel/redirect; return true/undefined to confirm. Async guards can return a Promise. Use route.meta to attach flags like requiresAuth.

vue3
<!-- router/index.js -->
const router = createRouter({ history, routes })

// global guard: runs on every navigation
router.beforeEach((to, from) => {
  if (to.meta.requiresAuth && !isAuthenticated()) {
    return '/login'  // redirect
  }
  // return true or undefined to confirm
})

// per-route guard
{ path: '/admin', component: Admin, beforeEnter: (to, from) => {
  if (!isAdmin()) return '/403'
}}

// in-component guard
<script setup>
import { onBeforeRouteLeave } from 'vue-router'
onBeforeRouteLeave((to, from) => {
  if (hasUnsavedChanges()) {
    return confirm('Discard changes?')
  }
})
</script>

Lazy Loading & Nested Routes

Nested routes render child components inside the parent's <RouterView>. Define children with paths relative to the parent. Empty path ('') matches the parent URL exactly. Dynamic import() code-splits each route into its own bundle. Use route.meta to attach data (auth requirements, titles) — accessible in guards via to.meta. Bundle related routes with a chunk name comment.

vue3
const routes = [
  {
    path: '/dashboard',
    component: () => import('../views/Dashboard.vue'),
    children: [
      { path: '', component: () => import('../views/Overview.vue') },
      { path: 'stats', component: () => import('../views/Stats.vue') },
      { path: 'settings', component: () => import('../views/Settings.vue') }
    ]
  },
  // group routes into one chunk
  {
    path: '/admin',
    component: () => import(/* webpackChunkName: "admin" */ '../views/Admin.vue'),
    meta: { requiresAuth: true, role: 'admin' }
  }
]
19

State Management (Pinia)

Defining a Store

Pinia is Vue's official state management (Vuex successor). defineStore with a setup function gives Composition API syntax — ref/computed are state/getters, returned functions are actions. The first arg is a unique store id. Setup stores are flexible (you can use any composable inside). Alternatively, use the Options syntax (state/getters/actions object) for a more Vuex-like feel.

vue3
<!-- stores/counter.js -->
import { defineStore } from 'pinia'

// setup syntax (composition API style)
export const useCounterStore = defineStore('counter', () => {
  const count = ref(0)
  const double = computed(() => count.value * 2)

  function increment() {
    count.value++
  }

  function asyncIncrement() {
    setTimeout(() => count.value++, 1000)
  }

  return { count, double, increment, asyncIncrement }
})

Using a Store

Call useStore() inside setup to access a store. For reactive destructuring, use storeToRefs() (like toRefs but skips actions). Actions can be destructured directly — they're bound to the store. Pinia allows direct mutation (counter.count = 5) and $patch for batch updates. Stores are singletons — the same instance across all components. Install Pinia with app.use(createPinia()).

vue3
<script setup>
import { useCounterStore } from '../stores/counter'
import { storeToRefs } from 'pinia'

const counter = useCounterStore()

// state and getters lose reactivity if destructured directly
// use storeToRefs to preserve it
const { count, double } = storeToRefs(counter)

// actions can be destructured directly (they're functions)
const { increment } = counter

// mutate state directly (Pinia allows it, unlike Vuex)
counter.count = 5
counter.$patch({ count: 10 })
counter.$patch(state => { state.count++ })
</script>

<template>
  <p>{{ count }} x 2 = {{ double }}</p>
  <button @click="increment">+</button>
</template>

Getters & Composing Stores

Getters are computed values derived from state. To use another store inside one, just call its useStore() — Pinia handles the dependency. This makes composing stores trivial (unlike Vuex modules). Getters can also reference other getters. For async operations, just write async functions in the store — no special 'actions' context like Vuex.

vue3
<!-- stores/cart.js -->
import { defineStore } from 'pinia'
import { useUserStore } from './user'

export const useCartStore = defineStore('cart', () => {
  const items = ref([])

  // getter (computed)
  const total = computed(() =>
    items.value.reduce((sum, i) => sum + i.price * i.qty, 0)
  )

  const itemCount = computed(() =>
    items.value.reduce((n, i) => n + i.qty, 0)
  )

  // compose other stores
  const userStore = useUserStore()
  const canCheckout = computed(() =>
    userStore.isLoggedIn && items.value.length > 0
  )

  function addItem(item) {
    items.value.push(item)
  }

  return { items, total, itemCount, canCheckout, addItem }
})

Subscriptions & Persistence

$subscribe fires on any state change (with mutation info and the new state). Use it for persistence (localStorage, sessionStorage) or syncing to external systems. $onAction hooks into action calls — useful for logging, analytics, or measuring action duration. The after/onError callbacks let you react to action completion or failure. Cleanup happens automatically on component unmount.

vue3
<script setup>
import { useCartStore } from '../stores/cart'

const cart = useCartStore()

// subscribe to any state change
cart.$subscribe((mutation, state) => {
  console.log('cart changed', mutation.type, state)
  // persist to localStorage
  localStorage.setItem('cart', JSON.stringify(state.items))
})

// subscribe to actions
cart.$onAction(({ name, after, onError }) => {
  console.log('action started:', name)
  after(result => console.log('action done:', name, result))
  onError(err => console.error('action failed:', name, err))
})
</script>

Reset & Store Modules

Options-syntax stores have a built-in $reset(); setup stores don't — implement reset() manually by reassigning each ref. Pinia is modular by design — each store is independent, no nested modules like Vuex. Compose stores by calling useOtherStore() inside one. Install Pinia globally with createPinia(). Stores are tree-shakable and have excellent TypeScript support and devtools integration.

vue3
<!-- stores/user.js -->
import { defineStore } from 'pinia'

export const useUserStore = defineStore('user', () => {
  const name = ref('')
  const isLoggedIn = ref(false)

  function login(n) { name.value = n; isLoggedIn.value = true }
  function logout() { name.value = ''; isLoggedIn.value = false }

  // Pinia setup stores don't have a built-in $reset
  // implement it manually
  function reset() {
    name.value = ''
    isLoggedIn.value = false
  }

  return { name, isLoggedIn, login, logout, reset }
})

<!-- main.js -->
import { createPinia } from 'pinia'
const pinia = createPinia()
app.use(pinia)
20

Transitions & Animation

Transition Component

Wrap a v-if/v-show element in <Transition> to animate enter/leave. Vue adds 6 classes at the right moments: *-enter-from (start state), *-enter-active (during transition), *-enter-to (end state), and *-leave-*. Define CSS transitions/animations on these classes. The name prop replaces the 'v-' prefix. Only one child element is allowed; for lists use TransitionGroup.

vue3
<template>
  <Transition name="fade">
    <p v-if="show">Hello</p>
  </Transition>
</template>

<style>
/* 6 classes: {name}-enter-{from,active,to}, {name}-leave-{...} */
.fade-enter-active, .fade-leave-active {
  transition: opacity 0.3s ease;
}
.fade-enter-from, .fade-leave-to {
  opacity: 0;
}
</style>

Transition Modes & Hooks

mode='out-in' fully animates the leaving element out before the entering one starts — perfect for swapping components (tabs) without overlap. mode='in-out' is rarely used. JavaScript hooks (@before-enter, @enter, @leave) let you drive animations with libraries like GSAP. Set :css='false' to tell Vue to skip CSS detection and rely solely on your JS hooks (slight perf win).

vue3
<template>
  <!-- 'out-in': current leaves, then new enters -->
  <Transition mode="out-in" name="fade">
    <component :is="currentTab" />
  </Transition>

  <!-- JavaScript hooks for GSAP/anime.js -->
  <Transition
    @before-enter="beforeEnter"
    @enter="enter"
    @leave="leave"
    :css="false"
  >
    <div v-if="show">Animated</div>
  </Transition>
</template>

TransitionGroup for Lists

TransitionGroup animates v-for lists with three behaviors: enter (new item), leave (removed item), and move (existing item changing position). The *-move class animates the FLIP technique — Vue computes position deltas and applies a transform transition. Set position: absolute on leave-active so the leaving item is taken out of flow, letting siblings slide smoothly into place.

vue3
<template>
  <TransitionGroup name="list" tag="ul">
    <li v-for="item in items" :key="item.id">{{ item.text }}</li>
  </TransitionGroup>
</template>

<style>
.list-enter-active, .list-leave-active { transition: all 0.5s; }
.list-enter-from, .list-leave-to { opacity: 0; transform: translateX(30px); }
/* the magic class for reordering */
.list-leave-active { position: absolute; }
.list-move { transition: transform 0.5s ease; }
</style>

Animated State with <Transition> Wrapper

You can override Vue's default class names with custom ones — enter-active-class and leave-active-class. This lets you plug into existing animation systems (Animate.css, Tailwind's animation utilities, custom keyframes). The pattern: define your animation on a class, point Vue's transition at it. This bridges Vue's lifecycle with any CSS animation framework.

vue3
<template>
  <button @click="show = !show">Toggle</button>
  <Transition
    enter-active-class="animate__animated animate__fadeIn"
    leave-active-class="animate__animated animate__fadeOut"
  >
    <div v-if="show" class="box">Content</div>
  </Transition>
</template>

<!-- combine with third-party animation libraries -->
<!-- Animate.css, Tailwind animations, custom keyframes -->

Suspense for Async

Suspense coordinates async setup — when a child component uses top-level await, it suspends, and Suspense shows the #fallback instead. Once all async children resolve, the default slot renders. This is elegant for data-fetching components. Note: Suspense is still experimental in Vue 3 — error handling requires onErrorCaptured. For production, most teams still use composables + loading state.

vue3
<template>
  <Suspense>
    <template #default>
      <AsyncComponent />
    </template>
    <template #fallback>
      <div>Loading...</div>
    </template>
  </Suspense>
</template>

<!-- AsyncComponent uses top-level await -->
<script setup>
import { ref } from 'vue'
const data = ref(null)
// top-level await suspends the component
const res = await fetch('/api/data').then(r => r.json())
data.value = res
</script>
21

Teleport & Suspense

Teleport Basics

Teleport renders content to a DOM element elsewhere in the document (often at body level), but the component keeps its parent's reactive context, props, emits, etc. This solves z-index/overflow issues with modals, tooltips, and dropdowns that would be clipped by parent overflow:hidden or stacking contexts. The 'to' selector targets the destination element.

vue3
<!-- Modal.vue -->
<template>
  <!-- the rendered content is moved to #modals in the DOM,
       but keeps the component's reactive context -->
  <Teleport to="#modals">
    <div class="modal">
      <h2>{{ title }}</h2>
      <slot />
      <button @click="$emit('close')">Close</button>
    </div>
  </Teleport>
</template>

<!-- index.html must have -->
<!-- <div id="app"></div> -->
<!-- <div id="modals"></div> -->

Conditional Teleport

The disabled prop lets you conditionally teleport — when true, content renders in place (not teleported). This is useful when the same component is used in different contexts (e.g. a tooltip that should be teleported in a normal layout but inline inside another modal). Multiple Teleports targeting the same element append in render order. Teleport works with v-if inside it.

vue3
<template>
  <!-- disabled: render in place (not teleported) -->
  <Teleport :to="target" :disabled="inline">
    <div class="popup" v-if="show">...</div>
  </Teleport>
</template>

<script setup>
const props = defineProps({
  inline: Boolean  // if true, render inline instead of teleporting
})
const target = '#popups'
</script>

<!-- Multiple Teleports to same target: appended in order -->

Teleport for Modals & Notifications

Teleport to body is the standard pattern for modals — the modal escapes any parent overflow/transform/z-index constraints. Combine with Transition for enter/leave animation. The @click.self on the backdrop closes when clicking outside (but not on content). Slots let the parent fully customize the modal's header, body, and footer while the modal component handles positioning and animation.

vue3
<!-- components/Modal.vue -->
<template>
  <Teleport to="body">
    <Transition name="modal">
      <div v-if="show" class="modal-backdrop" @click.self="$emit('close')">
        <div class="modal-content">
          <header><slot name="header" /></header>
          <slot />
          <footer><slot name="footer" /></footer>
        </div>
      </div>
    </Transition>
  </Teleport>
</template>

Suspense with Async Setup

When a child component's setup uses top-level await, it returns a Promise. Suspense catches it and shows #fallback until resolution. The suspended component's setup result is then used for rendering. Multiple async children are coordinated — Suspense waits for all. onErrorCaptured catches setup errors. Suspense is still experimental — for production, prefer manual loading state in composables.

vue3
<!-- Parent.vue -->
<template>
  <Suspense>
    <AsyncProfile :id="userId" />
    <template #fallback>
      <Spinner />
    </template>
  </Suspense>
</template>

<!-- AsyncProfile.vue -->
<script setup>
const props = defineProps(['id'])
// top-level await suspends this component
const profile = await fetch(`/api/users/${props.id}`).then(r => r.json())
// component only renders after this resolves
</script>

Nested Suspense & Async Components

Suspense can be nested — each level handles its own async boundary. The outer Suspense shows PageSkeleton while Dashboard (and everything it needs) loads; the inner one shows ChartSkeleton specifically for the chart. This granular loading improves UX: the page shell appears first, then individual sections fill in. Remember Suspense is experimental — test thoroughly and handle errors via onErrorCaptured.

vue3
<template>
  <Suspense>
    <template #default>
      <Dashboard>
        <Suspense>
          <template #default>
            <AsyncChart :data="data" />
          </template>
          <template #fallback>
            <ChartSkeleton />
          </template>
        </Suspense>
      </Dashboard>
    </template>
    <template #fallback>
      <PageSkeleton />
    </template>
  </Suspense>
</template>
22

Provide / Inject

Basic Provide & Inject

provide() makes a value available to all descendant components; inject() retrieves it anywhere down the tree. This avoids prop-drilling for deeply nested components. The second arg to inject is a default value (used if nothing is provided). Provided refs are reactive — descendants can mutate them and the parent sees changes. Use Symbol keys to avoid collisions in large apps.

vue3
<!-- Parent.vue -->
<script setup>
import { provide, ref } from 'vue'

const theme = ref('dark')
const user = ref({ name: 'Alice' })

// provide to all descendants
provide('theme', theme)
provide('user', user)
</script>

<!-- DeepChild.vue (any level of nesting) -->
<script setup>
import { inject } from 'vue'

const theme = inject('theme', 'light')  // default 'light'
const user = inject('user')

// these are reactive refs, just like in the parent
</script>

Read-only Provided State

Use readonly() to expose state without allowing direct mutation — descendants must call provided action methods to change it. This pattern (private mutable ref + readonly public view + action methods) is a lightweight alternative to Pinia for component-tree-scoped state. It enforces one-way data flow and prevents bugs from random mutations deep in the tree.

vue3
<!-- store.js (shared composable) -->
import { ref, readonly } from 'vue'

const _count = ref(0)  // private mutable state

export function useCountStore() {
  return {
    count: readonly(_count),  // consumers can't mutate
    increment: () => _count.value++  // only via action
  }
}

<!-- Provider.vue -->
<script setup>
import { provide } from 'vue'
import { useCountStore } from './store'

const store = useCountStore()
provide('countStore', store)
</script>

Typed Injection Keys

Use InjectionKey<Ref<Type>> to create a typed symbol key. This gives full TypeScript inference — inject knows the return type. Symbol keys avoid string collisions when multiple libraries provide values. For app-wide singletons, prefer Pinia over provide/inject — Pinia has devtools, persistence, and better testability. Use provide/inject for component-tree-scoped context (theme, form, locale).

vue3
<!-- keys.ts -->
import type { InjectionKey, Ref } from 'vue'
import { inject, provide } from 'vue'

export const ThemeKey: InjectionKey<Ref<string>> = Symbol('theme')

<!-- Provider.vue -->
<script setup>
import { ref } from 'vue'
import { ThemeKey } from './keys'

const theme = ref('dark')
provide(ThemeKey, theme)
</script>

<!-- Consumer.vue -->
<script setup>
import { inject } from 'vue'
import { ThemeKey } from './keys'

// TS knows theme is Ref<string> | undefined
const theme = inject(ThemeKey)
// with default
const theme2 = inject(ThemeKey, ref('light'))
</script>

Updating Provided Values

The recommended pattern: provide an object containing the reactive state AND the methods to mutate it. This keeps the API explicit and prevents random mutations. Children destructure the state and actions. This is essentially a mini-store — for anything beyond a few values, consider a composable that returns the same shape, or Pinia. The provided value is reactive because it contains a ref.

vue3
<!-- Provider.vue -->
<script setup>
import { ref, provide } from 'vue'

const count = ref(0)

// expose value + mutator together
provide('counter', {
  count,                       // reactive ref
  increment: () => count.value++,
  reset: () => count.value = 0
})
</script>

<!-- DeepChild.vue -->
<script setup>
import { inject } from 'vue'

const { count, increment, reset } = inject('counter')
// count is reactive, increment/reset are actions
</script>

<template>
  <p>{{ count }}</p>
  <button @click="increment">+</button>
  <button @click="reset">Reset</button>
</template>

App-level Provide

app.provide() makes a value available to every component in the app — useful for app-wide constants (API URLs, feature flags, environment config). Unlike component-level provide, this isn't reactive by default (unless you provide a ref). It's a good way to inject environment-specific config without importing env files everywhere. Combine with inject() in any component to access these globals.

vue3
<!-- main.js -->
import { createApp } from 'vue'
import App from './App.vue'

const app = createApp(App)

// app-level provide — available to ALL components
app.provide('apiUrl', 'https://api.example.com')
app.provide('apiKey', import.meta.env.VITE_API_KEY)

// useful for app-wide constants
app.provide('features', {
  darkMode: true,
  beta: false
})

app.mount('#app')

Was this helpful?

Learning path

Learn from scratch

Learn this language from the ground up with structured lessons.