Vue Tutorial · Lessons

Foundation

Basic

Medium

Advanced

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.
Reactive state count = ref(0) Render effect template → vnodes Real DOM patched minimally read → track diff + patch user event → count++ (write → trigger) re-run dependent effects

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.

  1. Setup — setup() / <script setup> runs; reactive state is created.
  2. Mount — the render function runs, vnodes are created and inserted into the DOM (onMounted).
  3. Update — reactive dependency changes → re-render → patch (onUpdated).
  4. 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. Use ref(), or toRefs() when you need to destructure.
  • Updates are batched per tick. Use nextTick() when you need the updated DOM.