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).