Vue Reactivity Explained: ref vs reactive (+ Cheat Sheet)
A complete guide to Vue reactivity: how ref and reactive really work, why destructuring breaks updates, when to use each, and a copy-paste cheat sheet.

Your counter is wired up. The click handler runs — you added a console.log, it prints 3. The number on screen still says 0.
Nothing is broken. Vue never subscribed to the thing you changed.
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()andreactive()deliberately, not by habit - Spot the four ways code silently loses reactivity — destructuring being the famous one
- Use
computed,watchandwatchEffectfor what each is actually for - Reach for
shallowRefandtoRawwhen 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 <script setup>, 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.)
- The problem: four counters, two silently broken
- The mental model: reactivity is a read-time subscription
ref(): a box you can pass aroundreactive(): a proxy you have to hold on to- The four ways reactivity gets lost
computed: cached, lazy, and worth understandingwatchvswatchEffect- 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.
Vue's reactivity has two halves, and almost everyone learns only the first.
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.
That single sentence pays for the whole article. Run the four counters through it:
- A — the template reads
a.valuewhile rendering. Subscription.a.value++writes it. Re-render. ✅ - B — the template reads
b.count(a property access on the proxy) while rendering. Subscription. ✅ - C —
countwas 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.counton the proxy, so there is a subscription — to the propertycountof that proxy object. ThenincDthrows 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:
1. Refs are unwrapped in templates, but only for top-level bindings from <script setup>. {{ count }} works; {{ someObject.count }} where someObject is a plain object holding a ref does not.
2. 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.
reactive() returns a Proxy of the object you gave it. No .value, which reads more naturally:
Three limits follow directly from "it's a proxy around that object":
- Objects only.
reactive(0)doesn't work — there is nothing to proxy. Primitives needref. - The proxy is the reactive thing, not your original object.
reactive(obj) !== obj. Mutateobjdirectly and no effect hears about it. - You cannot replace it. Reassigning the variable (counter D) points your variable somewhere else and leaves every subscription behind.
reactive() does handle arrays, Map and Set — mutations through the proxy (arr.push(x), map.set(k, v)) are tracked. It's the identity of the container that you must not swap.
Every "why isn't my template updating" question I've seen is one of these four. All four are the same missing subscription.
1. Destructuring a reactive() object. const { count } = state copies the current value out. Fix it with toRefs, which converts each property into a ref that keeps pointing at the source:
2. Replacing the object. Keep the proxy and mutate it, or use a ref when you genuinely need to swap the whole value:
3. Passing a value where you meant to pass a source. A function that takes count: number receives a snapshot. If a composable needs to keep watching something, take the ref (or a getter) and unwrap inside:
4. Reading after an await. A watchEffect only tracks what it reads synchronously. Anything read after the first await in the callback is invisible to the tracker:
A computed is an effect with a memory. It doesn't run when you create it — it runs when something reads it, then caches the result and returns the cache until one of its dependencies changes.
Two consequences that matter in real code:
- A
computedis not awatch. If the getter never gets read — the component isn't rendering it, nothing else touches it — it doesn't run. Never put side effects in one. - Cheap reads. Reading
total.valuea hundred times in a template costs one calculation. That's the reason to prefercomputedover a method call for derived values.
They look interchangeable and aren't:
Reach for watch when you need the old value, want it to stay lazy, or want to be explicit about the trigger. Reach for watchEffect when "run this whenever anything it touches changes" is genuinely what you mean.
Timing: callbacks flush before the component re-renders by default. If you need the updated DOM, ask for it — { flush: 'post' } — rather than reaching for nextTick inside the callback.
- Proxy identity.
reactive(raw) !== raw. Comparing something from a reactive array against a raw object with===can fail even when it "is" the same item.toRaw()gets you back to the original when you need identity. - A
refinsidereactiveis unwrapped.const s = reactive({ n: ref(0) })thens.nis0, not the ref. Convenient in templates, surprising in logic. Inside a plain array orMapit is not unwrapped. - Deep conversion has a cost.
ref(tenThousandRows)proxies objects as they're accessed. For large, read-mostly payloads useshallowRefand replace the whole value:js const rows = shallowRef([]) rows.value = await fetchRows() // one write, no deep proxying markRawkeeps something out of the reactive system entirely — a class instance, a chart object, a third-party controller that a Proxy would break.- Module-scope state is shared. A
refcreated at the top level of a module is one instance for the whole app. That's a feature in the browser and a cross-request data leak on the server, where one process serves many users. If it renders on a server, create state per request. - Arrays: index and length are tracked through the proxy, so
arr[0] = xandarr.length = 0both work in Vue 3 (this was a Vue 2 limitation people still carry around).
refby default. It works for every type, it survives being passed around, and.valueis a visible marker that this is reactive state. A codebase that usesrefeverywhere is boringly consistent.reactivefor an object you always mutate and never replace — a form model, a settings object. The nicer read syntax is a real benefit where it fits.- Never destructure a
reactiveobject withouttoRefs. Never reassign one. - Return refs from composables, and accept refs or getters as inputs.
computedfor derived values, watchers for side effects. If you're assigning to arefinside awatch, ask whether it should have been acomputed.- Reach for
shallowRef/shallowReactive/markRawwhen deep reactivity is cost without benefit.
Runs right in your browser — poke at it and watch the concept react live.
Think it clicked? Take the 8-question quiz →
Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.
Default to ref. It handles primitives, survives destructuring of the surrounding object, and can be replaced wholesale. Use reactive for an object you'll only ever mutate, where reading form.email instead of form.value.email is worth the constraint.
Because destructuring copied the value out and left the subscription behind. const { count } = toRefs(state) keeps it, then use count.value.
Yes. ref applies deep conversion to object values, so nested mutations are tracked. Use shallowRef to opt out.
Not by reassigning the variable — that leaves every subscriber pointed at the old proxy. Either mutate it (Object.assign(state, next)) or hold the object in a ref and write state.value = next.
Yes, and lazily evaluated: it recomputes on the next read after a dependency changes, and not at all if nothing reads it.
Yes — Vue proxies the collection methods, so map.set() and set.add() trigger updates. Keep the proxy; don't swap it for a fresh collection.
| Task | Code | Notes |
|---|---|---|
| Reactive primitive | const n = ref(0) | .value in JS, auto-unwrapped in templates |
| Reactive object | const s = reactive({}) | no .value; never reassign |
| Shallow (big payload) | shallowRef([]) | replace the whole value, no deep proxying |
| Destructure safely | const { a } = toRefs(state) | each property becomes a ref |
| One property as a ref | toRef(state, 'a') | writes flow back to state.a |
| Derived value | computed(() => a.value * 2) | cached, lazy, no side effects |
| React to a change | watch(a, (next, prev) => {}) | lazy; gives you the old value |
| React to anything read | watchEffect(() => {}) | eager; sync reads only |
| After the DOM updates | watch(a, cb, { flush: 'post' }) | default flush is pre-render |
| Escape the proxy | toRaw(s) / markRaw(obj) | identity checks; opt out entirely |
| Accept ref or value | unref(source) | composable-friendly inputs |
- Reactivity is a subscription between a property and an effect, created when the effect reads the property. Everything else follows.
- Losing reactivity is always the same bug: you copied a value out, or you replaced the object the subscription pointed at.
reffor values you replace;reactivefor objects you mutate and hold on to.computedfor derived data (cached, lazy, no side effects); watchers for side effects, withflush: 'post'when you need the DOM.- Deep reactivity is a default, not a law —
shallowRefandmarkRawexist for when it costs more than it gives.
The number on screen said 0 while the log said 3 because the template subscribed to a property nobody was writing to. Not a broken framework, not a missing nextTick — a subscription pointed somewhere else.
That's the question worth asking every time reactivity "doesn't work": which effect subscribed to which property, and did I write to that one? You'll answer it in about five seconds now.
Next Monday's episode goes one level down: what Vue does with those subscriptions when two of them change in the same tick.
Which of the four counters caught you out — or which reactivity bug cost you an afternoon?
🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.
Thanks for reading! Let's stay connected:
- ⭐ GitHub — follow me and star the projects: github.com/parsajiravand
- 💬 Discord — join the frontend best-practices community: discord.gg/d9KRhuAwQ
- 📸 Instagram — frontend best practices, daily: @bestpractice___
One post a day, in your inbox
Each one with a runnable playground and a quiz. No pitch, no digest, unsubscribe in one click.
0 comments
Sign in to join the discussion, like comments, and save articles for later.