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.
<!-- 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.
<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.
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.
# 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-tsdefineOptions & 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.
<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>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.
<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.
<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.
<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.
<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.
<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>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.
<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.
<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.
<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).
<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.
<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>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.
<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.
<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'.
<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.
<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.
<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>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).
<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.
<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.
<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.
<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.
<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>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.
<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).
<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.
<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.
<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.
<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>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.
<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.
<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.
<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.
<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.
<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>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).
<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.
<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).
<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.
<!-- 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.
<!-- 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>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.
<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.
<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.
<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.
<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.
<!-- 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>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.
<!-- 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.
<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()).
<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'.
<!-- 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).
<!-- 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>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: () => [].