Your counter is wired up. The click handler runs — you added a console.log, it prints 3. The number on screen still says 0.
That gap — between the value changed and Vue knows the value changed — is where almost every baffling Vue bug lives. It is also why ref and reactive feel like two competing ways to do the same job. They are not. Once you can see the subscription, the whole reactivity API stops being a list of functions to memorise and becomes one rule with a few consequences.
By the end of this article you'll be able to: Explain what Vue actually tracks, and at what moment it starts tracking Choose between ref() and reactive() deliberately, not by habit Spot the four ways code silently loses reactivity — destructuring being the famous one Use computed, watch and watchEffect for what each is actually for Reach for shallowRef and toRaw when deep reactivity is the wrong default Keep the cheat sheet at the end open while you work
You've built at least one Vue 3 component with , and you've used ref() because a tutorial told you to. You don't need to know how a Proxy works — we'll build that up.
This article is written against Vue 3.5.41, the current stable release as of August 2026. (Vue 3.6 is in release candidate as this goes out; the reactivity semantics below are the stable, documented ones.)
Table of contents The problem: four counters, two silently broken The mental model: reactivity is a read-time subscription ref(): a box you can pass around reactive(): a proxy you have to hold on to The four ways reactivity gets lost computed: cached, lazy, and worth understanding watch vs watchEffect Edge cases and gotchas Best practices: which one, when FAQ Cheat sheet
Here are four counters. Two work. Two update their data and never update the screen. Before reading on, decide which two.
A and B update. C and D don't. And notice what C and D have in common: the numbers do change. count in C really does become 1, 2, 3. The bug is not in the arithmetic. It's that nobody told Vue to care.
The usual explanation is "you can't destructure reactive objects." That's true, but it's a rule to memorise, and rules to memorise are what you fall back on when you don't have the model. Let's get the model instead.
The half people learn: reactive() wraps an object in a Proxy, so Vue can run code when you read or write a property.
The half that actually matters: that code only does something while an effect is running. An effect is a function Vue is currently executing on your behalf — a component's render function, a computed getter, a watchEffect callback. When a property is read during an effect, Vue records "this effect depends on this property." When the property is later written, Vue re-runs the effects that recorded it.
The mental model: reactivity is not a property of your data. It is a subscription between one property and one effect, created at the moment the effect reads the property. No read inside an effect, no subscription. No subscription, no update.
That single sentence pays for the whole article. Run the four counters through it: A — the template reads a.value while rendering. Subscription. a.value++ writes it. Re-render. ✅ B — the template reads b.count (a property access on the proxy) while rendering. Subscription. ✅ C — count was read once, during setup, outside any effect, and its value (0) was copied into a new local variable. The template renders that plain number. There was never a subscription to create. ❌ D — the template reads d.count on the proxy, so there is a subscription — to the property count of that proxy object. Then incD throws the proxy away and points the local variable at a brand-new plain object. Nothing wrote to the property anyone subscribed to. ❌
C and D are not two arbitrary rules. They are the same rule seen twice: the subscription is to a property on a specific object, and you have to keep reading it, on that object, from inside an effect.
Key concept: "Is this reactive?" is the wrong question. The right question is "which effect is subscribed to which property, and am I writing to that exact property?"
ref() sidesteps the problem C runs into by never handing you the value. It hands you a box with a .value property:
Because the value lives behind a property, reading it is a property access — trackable — and passing the box around passes the subscription target with it. That's the whole trick.
Two conveniences worth knowing precisely, because they're where refs feel magical and magic is hard to debug: Refs are unwrapped in templates, but only for top-level bindings from . {{ count }} works; {{ someObject.count }} where someObject is a plain object holding a ref does not. Refs are deep by default. ref({ user: { name: 'Ada' } }) converts that inner object with reactive() under the hood, so obj.value.user.name = 'Grace' triggers updates. This is convenient and it is not free — see shallowRef below.
