If you’ve been working with Vue for a while, you’ve probably learned the rules:
-
ref()holds a value -
reactive()makes an object reactive -
computed()derives state - changing state triggers a re-render
But none of that explains why it works.
Why does changing count.value make the component update?
Why does Vue know which component depends on which variable?
What exactly happens during render()?
To really understand Vue — and to write scalable, predictable code — you need to understand what’s happening under the hood.
Let’s open that black box.
And don’t worry — I’m going to explain this without computer-science jargon.
The simple idea behind all of Vue: “track” and “trigger”
The entire Vue reactivity system — every ref, reactive object, computed, watcher — is built on two operations:
track() // remember that some code used this value
trigger() // notify all code that depends on this value
That’s it.
Every time you read a reactive value → Vue calls track().
Every time you write to a reactive value → Vue calls trigger().
Vue keeps a mapping:
reactiveValue → list of functions that should re-run when it changes
Those “functions” are typically the component’s render function, or a computed getter, or a watcher.
This is the entire reactivity system in one sentence:
Vue re-runs functions that used a reactive value when that value changes.
The rest is implementation detail.
Let’s build a tiny Vue-like reactivity system from scratch
Vue 3 uses Proxy under the hood to intercept reads and writes.
Here’s a tiny toy example that mirrors how Vue works:
let activeEffect: Function | null = null
const effects = new Map()
function track(key: string) {
if (!activeEffect) return
let deps = effects.get(key)
if (!deps) {
deps = new Set()
effects.set(key, deps)
}
deps.add(activeEffect)
}
function trigger(key: string) {
const deps = effects.get(key)
if (!deps) return
deps.forEach(fn => fn())
}
function reactive(obj: Record<string, any>) {
return new Proxy(obj, {
get(target, key) {
track(key as string)
return target[key]
},
set(target, key, value) {
target[key] = value
trigger(key as string)
return true
}
})
}
Now watch the magic:
const state = reactive({ count: 0 })
function effect(fn: Function) {
activeEffect = fn
fn()
activeEffect = null
}
effect(() => {
console.log("count changed:", state.count)
})
state.count++
// → logs: "count changed: 1"
Congratulations — this is the core idea of Vue reactivity.
Vue’s real system is more advanced — optimized dependency tracking, cleanup, effect scopes, watcher flushing — but the foundations are exactly this.
How ref() works internally
A ref is just a reactive object with a .value property.
Inside Vue, the implementation is roughly:
class RefImpl {
private _value: any
private deps = new Set<Function>()
constructor(value: any) {
this._value = value
}
get value() {
trackRefValue(this)
return this._value
}
set value(newValue) {
this._value = newValue
triggerRefValue(this)
}
}
This is why you must use .value — it’s where access tracking happens.
When you write:
const count = ref(0)
count.value++
Vue performs:
-
track: remember that some component/computed uses
count.value - trigger: re-run those functions when the value changes
How reactive() works internally
Vue wraps your object in a Proxy. The proxy intercepts all get and set operations.
When you read a property:
state.user.name
Vue calls:
track(state.user, "name")
When you change one:
state.user.name = "Sarah"
Vue calls:
trigger(state.user, "name")
This lets Vue know exactly which fields of which objects your UI depends on.
The real magic: dependency tracking
Here’s a key insight junior developers often miss:
Vue tracks dependencies at runtime, not at compile time.
This means Vue knows exactly which reactive variables your component used while rendering.
Consider this component:
const count = ref(0)
const double = computed(() => count.value * 2)
When double is evaluated:
- it reads
count.value - so Vue records: “double depends on count”
Thus, when count.value changes → double re-runs automatically.
Vue builds a dependency graph dynamically, every time reactive values are used.
This is why:
- computed values update automatically
- watchers run when the reactive value they use changes
- components re-render when data they accessed changes
Why components re-render
Inside a component’s render function, Vue does this:
- evaluates the template → which reads reactive values
- tracks each read → maps reactive values → render function
- stores these dependencies
Later, if a variable changes:
- Vue sees which render functions depend on it
- schedules those components to update
So updating state.count will only re-render components that used state.count.
This is why Vue apps can stay performant even with lots of components.
Why some values don't trigger updates
Vue tracks accesses, not assignments.
Consider this:
const state = reactive({
items: []
})
state.items.push(1)
Push mutates an array without calling set on the array property (because the reference didn’t change).
But Vue patches array methods like push to call trigger() internally — otherwise arrays wouldn’t be reactive.
If you create a custom object with methods, Vue will not track them unless they use reactive data.
Why destructuring breaks reactivity
This confuses a lot of beginners:
const state = reactive({ count: 0 })
const { count } = state // ❌ reactivity lost
Why?
Because you copy state.count into a standalone variable.
It no longer goes through the reactive proxy.
This explains why Pinia recommends:
const store = useStore()
// ❌ breaks reactivity:
const { count } = store
// ✔ keeps reactivity:
const count = store.count
Or:
const { count } = storeToRefs(store)
Why watchers run too often
Because a watcher reacts to any change of any reactive variable used inside it.
For example:
watch(() => state.user, () => {
console.log("User changed")
})
This watcher triggers whenever any property of state.user changes.
Vue isn't being "weird".
It’s doing exactly what you told it:
“Tell me whenever this entire object changes.”
If you want precision:
watch(() => state.user.name, () => {
console.log("Name changed")
})
Why your app re-renders too much
Every unintended use of reactive data inside a render or computed function adds a dependency.
Vue will track it.
Vue will re-run it.
Vue will re-render.
Even if it was an accident.
Final mental model (save this forever)
If you truly understand this sentence, you understand Vue:
Vue re-runs anything that used a reactive value when that value changes.
That’s the whole system.
Everything else — computed, watchers, refs, reactive objects — is just different UI around the same engine.
Once this clicks, Vue stops feeling magical and starts feeling predictable.
Top comments (0)