Vue's promise: you describe what the screen should say and it keeps the page in step. One idea pays for it: reactivity. A reactive value records who read it, and writing to it re-runs exactly those readers — your component's render function among them. Nothing is diffed; Vue watched those properties being read. Almost every Vue-specific bug is a place where that link was quietly cut: a value copied out of its container, a dependency read after an await, a variable rebound. Learn where the link lives and the rest follows. Everything below was executed against Vue 3.5.40 first.
Each chapter opens with the short version. Tap one to read the detail.
Reactivity: what Vue is actually watching
~2 min
Vue tracks property reads, not values. ref() puts a value in a box you read through .value; reactive() wraps an object in a Proxy that intercepts every get and set. The subscription belongs to the container — copy a value out and the copy is an ordinary number that never updates again.
When a component renders, Vue notes every reactive property it read and subscribes that render to them. Writing to one re-runs its subscribers. So "reactive" is never a property of a number; it is a relationship between a container and the effects reading through it.
There are two containers. ref() boxes anything, primitives included: script code goes through .value, and templates unwrap it for you. reactive() returns a Proxy over an object — only objects, arrays, Map and Set — and that proxy is not the object you handed in. Vue's documentation recommends ref() as the default; the trap below is why.
const state = reactive({ count: 0 })
let { count } = state // count is now a plain 0
state.count = 99
console.log(count) // 0 — nothing is watching a local number
const { count: c } = toRefs(state)
state.count = 99
console.log(c.value) // 99 — still a container
The same cut appears in two other disguises. Rebinding the variable (state = reactive({...})) abandons the proxy every existing effect subscribed to, so they go quiet. And a ref reached through a plain object is not unwrapped in a template: {{ obj.id + 1 }} renders [object Object]1, while pulling id up to the top level renders 2.
When you want less tracking, shallowRef fires only when .value is replaced, and markRaw opts an object out entirely.
Rule: reactivity lives in the container, not the value — pass the ref, the reactive object, or a getter, never the property you just read out of one.
Derived values, and the watchers you mostly don't need
~2 min
computed() is lazy and cached: the getter runs only when something reads it and a dependency changed. A watcher exists for effects — a request, a timer, imperative work. If you can name the value you want, it is a computed; a watcher is for what happens, not what is.
A computed is a ref whose value is derived. Reading it returns the cache; changing a dependency invalidates the cache but does not run the getter, because nobody has asked yet. Executed: with a computed that nothing reads, two mutations of its source produced zero getter calls, while a watch on the same source fired. That laziness is exactly why a getter must be pure — you cannot predict when, or whether, a side effect inside one happens.
A watcher needs something it can subscribe to: a ref, a getter, a reactive object, or an array of those. The usual mistake is handing it a property instead.
const obj = reactive({ count: 0 })
watch(obj.count, cb) // passes the number 0
// [Vue warn]: Invalid watch source: 0 — and the callback never fires
watch(() => obj.count, cb) // a getter, so Vue can re-read it
Watching the reactive object itself works, but it is implicitly deep and both callback arguments are the same object — there is no "before" snapshot to compare. watchEffect drops the source list and subscribes to whatever it reads, which is terser and has one sharp edge: tracking stops at the first await, so a value read after it silently is not a dependency.
Callbacks are batched and run before the component re-renders — pass flush: 'post' when you need the updated page.
Rule: derive with computed, react with watch — and if a watcher only assigns to another piece of state, it wanted to be a computed.
The template language
~3 min
A template is not HTML with holes: it compiles to a render function, and each binding is one JavaScript expression evaluated in a sandbox. Directives read v-name:arg.modifier="value". Three decide most of a page's behavior: v-if creates and destroys, v-for needs a stable key, and v-model is two bindings wearing one name.
Bindings hold expressions, not statements, and they are sandboxed: only an allow-list of globals is visible, so Math works and something you attached to window does not. Executed, {{ typeof myGlobal }} rendered undefined and warned that the property was accessed but is not defined on the instance.
: is v-bind, @ is v-on, # is v-slot. Binding null or undefined removes the attribute rather than printing the word, as does false on a boolean attribute — an empty string is kept:
v-if genuinely adds and removes. In the executed run, hiding a child left a <!--v-if--> placeholder and ran that child's mounted and then unmounted hooks; v-show on a sibling only set display: none, so its instance and state survived. Toggle often, use v-show; toggle rarely or into something expensive, use v-if.
Keys are identity claims. v-for patches in place by default, so a key must be a stable value from the data — an index renumbers on every reorder and hands one row's state to a different item. On a single element Vue 3 evaluates v-if first, so it cannot see the loop variable: the executed template warned Property "i" was accessed during render but is not defined on instance and rendered nothing. Move the v-for to a wrapping <template>.
Handlers split the way expressions do. @click="save" is a reference and receives the native event; anything else (@click="count++") compiles as a statement, where the event is $event. Modifiers become generated code in the order written, which is why @click.self.prevent and @click.prevent.self differ — and why .passive, a promise not to cancel, contradicts .prevent.
v-model expands to a value binding plus a handler chosen by element type: @input for text, @change for a checkbox. The surprise is that a static value or checked attribute is then ignored — bound state is the only source of truth, and initial values belong in JavaScript. .number casts through parseFloat but keeps the original string when parsing fails, so a half-typed entry never becomes NaN.
Rule: ask whether you want an element gone or merely invisible, key every list item by data rather than position, and read v-model as the two halves it expands to.
Single-file components, and which API to write them in
~2 min
<script setup> is the setup function: top-level bindings reach the template with no return statement, and the block runs once per instance. defineProps, defineEmits and defineModel are compiler macros, not imports. For new code write the Composition API; the Options API is supported indefinitely, not deprecated.
A single-file component compiles <template>, <script> and <style> into one module. An app is assembled by createApp(Root) and renders nothing until .mount(), so configuration and global registration must happen before that call.
Inside <script setup>, every top-level binding — variables, functions, imported components — is available to the template automatically.
<script setup>
defineProps({ id: Number }) // a macro: compiled away, never imported
const emit = defineEmits(['close'])
</script>
<template><button @click="emit('close')">{{ id }}</button></template>
Because the macros are erased at compile time, their arguments are hoisted to module scope — so a macro argument may reference an import but not a local declared beside it. Such a component is also closed by default: a parent holding a template ref to it sees only what defineExpose hands out. <style scoped> limits rules to that component's own elements, with :deep() as the way out.
Which API to write is a real decision. Default to the Composition API for new work. Its logic reuse is ordinary function calls you can see, name and rename — where a mixin merged into your component invisibly and collided on keys. It groups code by concern instead of scattering one feature across data, computed, methods and watch. And because it is plain variables, types follow without ceremony.
The honest other side: Vue's documentation states the Options API is not going away and remains a solid choice at low to medium complexity, and it runs on the same reactivity engine. What is genuinely costly is mixing both in one component: a reader must hold two mental models at once.
Rule: one API per component, Composition by default — and configure the app fully before you mount it.
The component contract: props, events, slots, injection
~3 min
A prop is read-only — assigning to one warns target is readonly. Anything undeclared falls through onto the child's root element. Slot content is compiled in the parent's scope, whatever child renders it. Injection reaches past the components between, but stays live only if you provided a container.
A component talks to its neighbors four ways: props down, events up, slots for markup, injection past everyone in between.
Declaring a prop consumes it; anything undeclared falls through to the child's root element instead. Executed, <Declared title="T" data-x="1" /> rendered <button data-x="1">T</button> — the declared title became a prop and never appeared as an attribute. class and style merge rather than replace, so a parent's class="large" on a child rooted at class="btn" gives class="btn large". A component with several root nodes has nowhere to put any of this, so Vue applies nothing and warns Extraneous non-props attributes (class) were passed to component but could not be automatically inherited because component renders fragment or text or teleport root nodes — bind $attrs on the node you meant, and use inheritAttrs: false when the target is an inner element.
Props flow one way. Writing to one warns and is overwritten on the parent's next render. Mutating a field of an object prop does not warn — objects arrive by reference — but it couples the child to data it does not own, which is the version of this bug that survives review. The return path is emit: declaring your events documents the contract and removes those listeners from $attrs. Declare in camelCase, listen in kebab-case; these do not bubble past the direct parent. defineModel() (3.4+) packages the pair as a writable ref — the modelValue prop plus its update:modelValue event.
Slots pass markup rather than data, and their scope catches everyone once. Executed, a parent writing {{ secret }} inside <Box> rendered the parent's value even though Box had its own secret: the expression was written in the parent's template, so it reads the parent's bindings. A scoped slot is the deliberate exception — the child binds values onto its <slot> and the parent destructures them, so the child owns behavior and the parent owns markup.
<!-- child --> <li v-for="i in items"><slot name="item" v-bind="i"/></li>
<!-- parent --> <template #item="{ title }">{{ title }}</template>
provide/inject covers the last direction: a value needed far below, threaded through components that do not care. Both calls run synchronously in setup and the nearest provider wins. The mistake is providing a plain value — it is read once and never updates again. Provide a ref or computed, keep writes at the source with an updater alongside it, and wrap it in readonly() to enforce that rather than agree it.
Rule: data goes down and requests come back up — a child that needs a change emits, and everything it receives is either read-only or a container someone else owns.
Instances: creation, mounting, and template refs
~2 min
Every use of a component is a separate instance with its own state. <script setup> is the creation stage: no elements exist yet and a template ref is still null. A hook attaches to whichever instance is active when you register it, which is why one registered from a callback attaches to nothing.
Using a component twice creates two instances with independent state — which is why the Options API's data must be a function: one object would be shared by every instance.
Setup runs before anything is rendered, so reaching for an element there is the classic mistake; onMounted is the first point at which the Document Object Model (DOM) exists. Executed, a ref declared with useTemplateRef('box') (3.5+, matching ref="box" in the template) read null during setup and held the element inside onMounted.
The popular version of the registration rule — "never after an await" — is subtly wrong. In <script setup> the compiler wraps a top-level await so the instance context is restored afterwards — executed, an onMounted registered after one ran normally. Write the same thing in a hand-rolled async setup(), a setTimeout or a .then() and the instance is gone:
async setup() { // hand-written, NOT <script setup>
await Promise.resolve()
onMounted(() => { /* never runs */ })
// [Vue warn]: onMounted is called when there is no active component
// instance... register lifecycle hooks before the first await statement.
}
onUnmounted is where everything setup started gets undone — listeners, timers, subscriptions. Children mount before their parents, and unmount before them.
Registering components needs one decision: import locally by default, since app.component() registers globally and keeps unused components in the bundle. Names are PascalCase in a single-file component but kebab-case in a template written directly in the page, which the browser lowercases before Vue sees it.
Rule: nothing about the page exists until onMounted, and a hook only binds while its own instance is the active one — register from setup's call stack, never from a callback.
Reuse: composables, and the narrower tool beside them
~2 min
A composable is a plain function using the reactivity APIs, called synchronously at the top of setup — that call binds its hooks and watchers to your component. Return refs, not values, so the caller can destructure without losing reactivity. Custom directives are the narrower tool, for raw element access.
There is no special machinery here: a composable is a function named useSomething that creates refs, registers hooks and returns them. Each call makes fresh state, so two components calling useMouse() get their own coordinates — while state declared at module scope is shared by every caller, which is how you build a singleton on purpose.
Two conventions carry most of the value, and both are the first chapter reappearing. Return a plain object of refs, so const { x, y } = useMouse() destructures without cutting the link — returning plain values makes the same mistake as destructuring a reactive(). And accept a ref, a getter or a plain value, normalizing with toValue() (3.3+), so a caller can pass () => props.id and have the composable re-run when it changes.
export function useMouse() {
const x = ref(0), y = ref(0)
const update = (e) => { x.value = e.pageX; y.value = e.pageY }
onMounted(() => window.addEventListener('mousemove', update))
onUnmounted(() => window.removeEventListener('mousemove', update))
return { x, y } // refs, so destructuring survives
}
Call it synchronously in setup: that is what lets the hooks above attach to the calling component, and what stops its watchers when that component goes away. Register anything, clean it up — a composable that leaks leaks once per call site. It is also why Vue 3 stopped recommending mixins: you can see where each binding came from, and rename it when two collide.
Custom directives are the smaller neighbor, for logic that genuinely needs the element — focus, measurement, a third-party widget. A variable named vFocus in <script setup> works as v-focus with no registration, and its hooks receive the element plus a binding carrying value, arg and modifiers. On a multi-root component a directive is ignored with a warning.
Rule: reuse stateful logic with a composable that returns refs, and reach for a directive only when the thing you need is the element itself.
The built-ins worth knowing
~2 min
Four components you would otherwise reinvent badly. <Transition> adds enter and leave classes around a single element; <KeepAlive> caches an instance instead of destroying it; <Teleport> moves the node without moving the component; <Suspense> waits on async setup and is still marked experimental.
<Transition> wraps one element or component and needs a real entrance or exit to react to — v-if, v-show, a changing <component :is>, or a changed key. A plain data change is not one of those, which is the usual reason a transition "does nothing".
<Transition name="fade" mode="out-in"><p v-if="show">hi</p></Transition>
<!-- fade-enter-from → fade-enter-active → fade-enter-to, mirrored for leave -->
<!-- without mode="out-in" both elements exist at once and the layout jumps -->
The name prop renames all six classes from the default v- prefix. <TransitionGroup> is the list version: keys required, plus a move class so repositioned items animate.
<KeepAlive> caches a toggled component instead of unmounting it, preserving its state and rendered nodes. Cached components swap mount and unmount for activated and deactivated — worth knowing if your "run on mount" work stops running. include and exclude match the component's own name, and max evicts the least recently used entry.
<Teleport to="body"> moves only the rendered node. Props, injected values and emitted events still travel the component tree, so a modal rendered at the end of the page is still, in every way that matters to your code, a child of the component that declared it. That is the point: it escapes a parent's overflow or stacking context, not your architecture.
<Suspense> shows a fallback until the async dependencies inside it resolve — a component with async setup(), or an async component. Vue's documentation still labels it experimental with an API that may change, so adopt it with an exit plan.
Rule: reach for these before hand-rolling — and when a transition does nothing, check that something is actually entering or leaving.
Scaling up: routing and shared state
~2 min
Vue Router maps paths to components rendered inside <RouterView>; useRoute() is the reactive current location and useRouter() is what you navigate with. A Pinia store is a reactive object — so destructuring one loses reactivity for exactly the reason the first chapter gave, and storeToRefs is the fix.
A route record pairs a path with a component. createWebHistory gives clean paths but shifts work to the server: every unknown path must now serve your index.html, so a genuine 404 needs a catch-all route in the app. createWebHashHistory keeps everything after #, which the server never sees, and needs no configuration at all.
The behavior that catches people is reuse. Navigating from /users/1 to /users/2 matches the same route, so the same instance is kept and setup does not run again — your fetch-on-mount silently shows the previous user. Watch route.params.id, or use the beforeRouteUpdate guard. Guards run in a fixed order: leave guards on what you are departing, global beforeEach, beforeRouteUpdate on reused components, per-route beforeEnter, enter guards, then beforeResolve. Returning nothing continues, false cancels, a location redirects.
Pinia is the state half. defineStore(id, setup) returns a composable in which a ref is state, a computed is a getter and a function is an action; every call within one app returns the same instance, and calling one before the plugin is installed throws.
const store = useCounter()
const { count } = store // a plain 0, frozen
const { count: c } = storeToRefs(store) // a ref, still live
store.inc(); store.inc()
// store.count 2 | count 0 | c.value 2
Those numbers are executed output. Actions destructure fine — they are bound functions, not values — so the rule is narrower than "never destructure a store".
Rule: treat a store like any reactive object, and never assume a route change remounted anything.
How updates get cheap, and where they get expensive
~2 min
Vue compiles your template rather than merely reading it, so the runtime already knows which nodes can change and which bindings on them. Most slowness left after that is yours: props rebuilt every render, deep reactivity over huge data, or simply too many nodes on the page.
A template becomes a render function returning a tree of plain objects, and running it is a reactive effect — which is why reading a ref inside a component subscribes that component to it. On a change the effect re-runs and the renderer patches the differences.
Compilation is what makes that cheap. Static parts are created once and reused, never diffed. Dynamic elements carry a flag naming which binding can change, so the runtime checks a class or a text node rather than every attribute. And a block collects its dynamic descendants into a flat array, so patching costs what your dynamic content costs, not what your markup weighs.
What the compiler cannot know is whether your props are equal. This is the most common self-inflicted slowdown, and easy to miss:
<Row v-for="r in rows" :id="r.id" :active-id="activeId"/>
<!-- activeId changes → every row updates -->
<Row v-for="r in rows" :id="r.id" :active="r.id === activeId"/>
<!-- only the two rows whose value flipped update -->
A computed returning a fresh object every evaluation does the same damage: a new object is never equal to the old one. For very large structures, shallowRef avoids deep-proxying data you replace wholesale; for very long lists, the answer is rendering fewer nodes, not a faster diff. And ship the production build — the development one carries warnings and devtools hooks.
One security note belongs here: v-html writes raw markup and must never receive user input, and compiling a user-supplied template is running their code.
Rule: measure before optimizing, and when a list is slow ask what changed identity this render rather than reaching for a cache.
Written by Keentune. We are not affiliated with or endorsed by the organizations whose documentation informs this guide, and any linked sources belong to their respective owners.
All exam, test, and product names and trademarks are the property of their respective owners and are used here for identification and reference only. Keentune is independent study practice — not affiliated with, authorized, or endorsed by any of these organizations.