Vue 3 from API to Internals: Composition API, Proxy Reactivity, VDOM Patching and Compiler Hints
Key takeaways
Vue 3 pairs the Composition API with a Proxy-based reactivity system, Virtual DOM patching, and template compiler optimizations. This guide covers usage and how the pieces fit together under the hood.
Introduction
Vue is a progressive framework for building UIs with declarative templates and reactive state. Vue 3 added the Composition API, which makes logic reuse and TypeScript inference much easier than the Options API did, and rebuilt the runtime around Proxy-based reactivity, a Virtual DOM renderer, and a template compiler that passes optimization hints to that renderer.
This article covers the everyday APIs first and then the model underneath them: how dependency tracking and update scheduling work, how VNodes are patched, how <script setup> maps to the runtime, what the compiler optimizes, and which patterns matter once an app grows.
Vue 3 at a glance
In a typical Vue 3 component, state and logic live in <script setup> and are extracted into reusable functions called composables when they need to be shared. Reactive objects are wrapped in a Proxy, and ref wraps a single value in an object whose .value property is tracked; templates unwrap refs automatically, so you write count there instead of count.value. Templates are compiled into render functions that produce VNode trees, and the runtime patches the real DOM when the state those functions read changes. Vue Router, Pinia, and Vite are the usual companions.
The Options API is still fully supported, but the Composition API with <script setup> is the default for new applications and what the official docs lead with.
ref, reactive, computed, watch
ref (single values)
ref creates a reactive reference. In <script>, read and write it through .value; in templates, Vue unwraps top-level refs for you.
<script setup lang="ts">
import { ref } from 'vue';
const count = ref(0);
function increment() {
count.value++;
}
</script>
<template>
<p>Count: {{ count }}</p>
<button type="button" @click="increment">+1</button>
</template>
reactive (objects)
reactive returns a deeply reactive Proxy of an object. The catch is that reactivity lives on the Proxy, not on the values: destructuring properties out of it copies plain values and loses the connection. Use toRefs when you need to destructure a reactive object, or storeToRefs for the same job on a Pinia store.
<script setup lang="ts">
import { reactive } from 'vue';
const state = reactive({
count: 0,
user: { name: 'Ada', email: '[email protected]' },
});
function increment() {
state.count++;
}
</script>
<template>
<p>{{ state.count }} — {{ state.user.name }}</p>
<button type="button" @click="increment">+1</button>
</template>
The same trap appears when you replace the whole object: state = reactive({...}) on a let variable leaves the template bound to the old Proxy. A ref holding an object avoids both problems, because you always go through .value, which is why many teams standardize on ref for local state.
computed
computed derives a value from other reactive state. It tracks whatever the getter reads, caches the result, and recomputes lazily, only when it is read again after one of those dependencies changed.
<script setup lang="ts">
import { ref, computed } from 'vue';
const firstName = ref('John');
const lastName = ref('Doe');
const fullName = computed(() => `${firstName.value} ${lastName.value}`);
const reversedName = computed(() =>
fullName.value.split('').reverse().join(''),
);
</script>
<template>
<input v-model="firstName" />
<input v-model="lastName" />
<p>{{ fullName }}</p>
<p>{{ reversedName }}</p>
</template>
Keep computed getters free of side effects such as fetching or mutating other state. Vue may call them at different times than you expect (or not at all if nothing reads them), so side effects belong in a watcher.
watch and watchEffect
watch observes explicit sources and gives you the old and new values; watchEffect runs immediately and re-runs whenever any reactive value it read last time changes.
<script setup lang="ts">
import { ref, watch, watchEffect } from 'vue';
const count = ref(0);
const message = ref('hello');
watch(count, (newVal, oldVal) => {
console.log(`count: ${oldVal} → ${newVal}`);
});
watch([count, message], ([c, m]) => {
console.log('both:', c, m);
});
watchEffect(() => {
console.log(`effect sees count=${count.value}`);
});
</script>
A common mistake is watching a property of a reactive object directly, as in watch(state.count, ...). That passes a plain number, which Vue cannot watch, and you get a warning instead of updates. Pass a getter, watch(() => state.count, ...), so Vue can track the read. Also note that watchEffect only tracks reads that happen synchronously; anything read after an await inside the callback is not a dependency.
Composables and Pinia
A composable is just a function, conventionally named useXxx, that creates refs, computeds, and watchers and returns them. Because it runs inside the calling component’s setup, any watchers it creates are tied to that component and stopped when the component unmounts. Pinia, the officially recommended store for Vue 3, builds on the same idea: a setup-style store defined with defineStore is essentially a composable whose state is shared app-wide. Store state is ordinary reactive data, so components that read it are tracked and updated by the same reactivity and effect system described below. The Pinia guide covers stores in depth.
Internals: Proxy-based reactivity
Vue 3 uses Proxy and Reflect to intercept reads and writes on reactive objects. Vue 2 used Object.defineProperty, which can only intercept keys that already exist when the object is made reactive, so adding a new property or setting an array element by index needed Vue.set. With a Proxy, added and deleted keys, in checks, and iteration can all be intercepted, so those cases simply work.
track and trigger
The runtime is built around effects: units of work that read reactive data, such as a component’s render function, a watchEffect callback, or a computed getter. While an effect runs, every reactive read calls track, which records that this effect depends on that object and key. When a key is written, trigger looks up the effects that depend on it and schedules them to run again. Dependencies are re-collected on every run, so an effect that reads different keys in different branches only re-runs for the keys it actually read last time.
Component re-renders are not executed immediately on each write. They are pushed into a queue that is flushed in a microtask, so ten writes in the same synchronous block produce one re-render, and the queue is sorted so parents update before children.
ref and reactive
reactive wraps an object in a Proxy for deep reactivity. ref stores a value inside a small object whose value getter and setter call track and trigger, so a primitive can take part in the same dependency graph; if you put an object into a ref, that object is made reactive with reactive as well. Variants such as readonly, shallowReactive, and shallowRef change how deep the tracking goes or whether writes are allowed, but use the same mechanism.
Scheduling and nextTick
Because updates are queued, the DOM has not changed yet on the line right after an assignment. await nextTick() waits until the queued updates have been applied to the DOM, which is what you need before measuring an element’s size or moving focus to an element that just appeared. It does not wait for the browser to paint; if you need that, use requestAnimationFrame.
Virtual DOM and the patch algorithm
Vue compiles templates to render functions that return VNode trees. Comparing the previous tree with the next one and applying the minimal set of DOM operations is called patching.
What is a VNode?
A VNode is a plain JavaScript object describing a DOM element or component: its type, props, children, and optimization hints from the compiler. A component VNode holds the component definition and its slots, and the runtime renders it into its own subtree.
Patching and keyed children
If two VNodes in the same position have different types, the runtime unmounts the old one and mounts the new one. If the types match, it updates the props, text, and children in place.
For lists, key tells the runtime which old child corresponds to which new child. With keys, Vue 3 first syncs matching nodes from the start and end of the list, then, for the unordered middle section, computes a longest increasing subsequence of the old positions (using a greedy algorithm with binary search) to find the largest set of nodes that can stay where they are, and moves only the rest. This is also why using the array index as key is risky: after an insertion or deletion, the same index refers to a different item, so Vue reuses the wrong element and any local state in it, such as input text or a child component’s state, ends up attached to the wrong row.
Block tree
The compiler marks the root of each template, and each v-if branch or v-for fragment, as a block. A block keeps a flat array of only its dynamic descendants, so on update the patcher walks that array instead of the whole subtree. Static structure between dynamic nodes is never compared at all. This is the main reason Vue’s compiler hints matter, as the next sections show.
Composition API: implementation angle
setup and the component instance
setup(props, ctx) runs once per component instance, before the render effect is created. Refs, computeds, and watchers made there belong to that instance, and the watchers are stopped automatically when it unmounts. In a component that uses setup() without <script setup>, whatever setup returns is exposed to the template (and to this in any Options API code).
<script setup> compilation
<script setup> is compiled into a regular setup function. Top-level bindings, including imported components, are available in the template directly without registration. Macros such as defineProps and defineEmits are not real imports; the compiler replaces them with the component’s props and emits options, which is how it can derive runtime prop validation from a TypeScript type. Since Vue 3.5, destructuring the result of defineProps keeps reactivity because the compiler rewrites the destructured names into props.x accesses.
provide / inject
A parent provides values that any descendant can inject, resolved by walking up the component chain. Provide a ref or computed rather than its current value: the injected ref stays connected to the source, while an unwrapped snapshot never updates. A common pattern is to provide a readonly ref plus a function for changing it, so descendants cannot mutate shared state behind the provider’s back.
Compiler optimizations
Static hoisting
Template nodes that never change are created once and reused on every render (hoisted out of the render function, or cached on the instance in Vue 3.5 and later), so they cost nothing per update.
Patch flags
The compiler annotates each dynamic node with a bitflag describing what can change: only text, only class, only style, or a specific list of props. At runtime the patcher checks the flag and compares just those fields instead of diffing every prop. In a template like <div :class="cls">{{ msg }}</div>, an update compares only the class and the text.
v-once
v-once renders an element or component once and treats it as static afterwards. Use it only for content whose data truly never changes after the first render; if the data does change later, the screen silently keeps the old value.
Patterns for larger apps
Large data: shallowRef and markRaw
Making a large array of objects deeply reactive means creating Proxies lazily on access and tracking every nested read, which adds overhead and memory when the UI only cares about replacing the list as a whole. shallowRef tracks only the .value assignment, so you update it by assigning a new array. markRaw excludes an object from reactivity entirely, which is appropriate for third-party class instances such as a map or chart library object; wrapping those in a Proxy can also break code that compares object identity.
v-memo
v-memo="[a, b]" skips re-rendering a subtree unless one of the listed values changed. It is useful for very long v-for lists where most rows stay the same, but if you leave out a value the row actually depends on, the row will show stale data with no warning. Reach for it only after profiling shows the list re-render is the bottleneck.
Async components and code splitting
defineAsyncComponent(() => import('./HeavyChart.vue')) puts a component in its own chunk that loads on first use. Its loadingComponent, errorComponent, delay, and timeout options control what users see while it loads. Route-level splitting with Vue Router’s lazy component: () => import(...) uses the same mechanism.
Suspense and error handling
<Suspense> shows fallback content while async components or components with an async setup resolve. As of Vue 3.5 it is still marked experimental, so check its status before relying on it. For errors, app.config.errorHandler gives you one place to report uncaught errors from components, and onErrorCaptured lets a parent component handle errors from its descendants.
Frequently Asked Questions (FAQ)
Why did my component stop updating after I destructured a reactive object?
reactive() tracks access through a Proxy, so destructuring (const { count } = state) copies plain values out and the new variables are no longer reactive. Destructuring defineProps() had the same problem before Vue 3.5. Use toRefs(state) to get refs that stay linked to the source, or keep accessing state.count directly; this is one reason many teams default to ref for local state.