How Vue Works
Reactivity, the virtual DOM and the render cycle.
Vue is a progressive JavaScript framework for building user interfaces. "Progressive" means you can drop it onto a single element of an existing page, or build an entire single-page application with routing, state management and a build pipeline. It's the same core library either way.
This lesson covers the three ideas everything else builds on: declarative rendering, reactivity and the virtual DOM.
Declarative rendering
With plain JavaScript you write imperative code. You find an element, change its text and remember to do it again every time the data changes:
// Imperative: you manage the DOM yourself
let count = 0;
const button = document.querySelector('#btn');
button.addEventListener('click', () => {
count++;
button.textContent = `Clicked ${count} times`;
});
With Vue you describe what the UI should look like for a given state. Vue keeps the DOM in sync for you:
<script setup>
import { ref } from 'vue';
const count = ref(0);
</script>
<template>
<button @click="count++">Clicked {{ count }} times</button>
</template>
Try it. This counter is a real Vue app running on this page:
Reactivity: how Vue knows what changed
Vue wraps your state in reactive proxies. A JavaScript Proxy lets Vue intercept every read (get) and write (set) on an object:
- When a component renders, every reactive property it reads is recorded as a dependency of that render. This is called tracking.
- When you later write to one of those properties, Vue looks up everything that depends on it and schedules it to re-run. This is called triggering.
A stripped-down version of what Vue does internally looks like this:
let activeEffect = null;
const deps = new Map(); // key -> Set of effects
function reactive(target) {
return new Proxy(target, {
get(obj, key) {
if (activeEffect) {
if (!deps.has(key)) deps.set(key, new Set());
deps.get(key).add(activeEffect); // track
}
return obj[key];
},
set(obj, key, value) {
obj[key] = value;
deps.get(key)?.forEach(effect => effect()); // trigger
return true;
},
});
}
function effect(fn) {
activeEffect = fn;
fn(); // run once so its reads get tracked
activeEffect = null;
}
const state = reactive({ count: 0 });
effect(() => console.log(`count is ${state.count}`)); // logs "count is 0"
state.count++; // logs "count is 1"
From Vue 2 to Vue 3. Vue 2 used Object.defineProperty, which couldn't detect new properties or array index assignment. That's why Vue.set existed. Vue 3's Proxy-based reactivity removed those caveats.
ref vs reactive
ref() |
reactive() |
|
|---|---|---|
| Holds | Any value (primitives too) | Objects, arrays, Maps, Sets |
| Access in JS | count.value |
state.count |
| Access in template | Auto-unwrapped: {{ count }} |
{{ state.count }} |
| Reassign whole value | ✅ list.value = [] |
❌ loses reactivity |
| Destructuring | Keeps reactivity | Breaks it (use toRefs) |
Most teams default to ref() everywhere for consistency. ref() works for every value type, and the .value makes it obvious in script code which variables are reactive.
Seeing the pitfalls
The most common reactivity bug is losing the connection to reactive state. Destructuring a reactive() object copies its current values into plain variables. Those variables are snapshots: Vue can't intercept reads or writes on them. toRefs() fixes this by turning each property into a ref that points back to the original object.
const state = reactive({ count: 0 });
const { count } = state; // ❌ plain number 0, never updates
const { count: live } = toRefs(state); // ✅ ref linked to state.count
Click state.count++ and watch which values follow along:
The virtual DOM and the render cycle
Vue compiles your template into a render function. Calling it returns a lightweight JavaScript tree describing the UI, made of virtual nodes (vnodes):
// <button class="btn">Clicked {{ count }} times</button> compiles roughly to:
import { h } from 'vue';
const render = () => h('button', { class: 'btn' }, `Clicked ${count.value} times`);
When state changes, Vue re-runs the render function to get a new vnode tree. It diffs the new tree against the old one and applies only the minimal set of real DOM operations. Here, that's updating one text node.
Vue's compiler is smarter than a plain virtual DOM. At build time it marks which parts of the template are static (never change) and which bindings are dynamic. At runtime the diff skips static subtrees entirely. Vue calls this compiler-informed virtual DOM.
Batched, asynchronous updates
DOM updates are not applied synchronously. If you change state three times in one tick, Vue renders once:
import { ref, nextTick } from 'vue';
const count = ref(0);
count.value++;
count.value++;
count.value++;
// DOM still shows the old value here
await nextTick();
// DOM now shows 3. Only one re-render happened.
The demo below makes this visible. It counts how many times the component has actually rendered:
The component lifecycle in one picture
Every component instance goes through the same stages. You'll hook into them in Computed, Watchers & Lifecycle.
- Setup —
setup()/<script setup>runs; reactive state is created. - Mount — the render function runs, vnodes are created and inserted into the DOM (
onMounted). - Update — reactive dependency changes → re-render → patch (
onUpdated). - Unmount — the component is removed; effects and listeners are cleaned up (
onUnmounted).
Key takeaways
- You describe what the UI should be; Vue works out how to update the DOM.
- Reactivity is built on
Proxy. Reads are tracked, writes trigger re-renders. - Templates compile to render functions that produce a virtual DOM. Vue diffs and patches only what changed.
- Destructuring
reactive()breaks reactivity. Useref(), ortoRefs()when you need to destructure. - Updates are batched per tick. Use
nextTick()when you need the updated DOM.