← blog
VueAugust 24, 2026 · 14 min read

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.

Parsa Jiravand · Frontend engineer · building bestpractic
Vue Reactivity Explained: ref vs reactive (+ 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() 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 <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.)

Here are four counters. Two work. Two update their data and never update the screen. Before reading on, decide which two.

Vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<script setup> import { ref, reactive } from 'vue' // A const a = ref(0) const incA = () => a.value++ // B const b = reactive({ count: 0 }) const incB = () => b.count++ // C let { count } = reactive({ count: 0 }) const incC = () => count++ // D let d = reactive({ count: 0 }) const incD = () => { d = { count: d.count + 1 } } </script> <template> <button @click="incA">A: {{ a }}</button> <button @click="incB">B: {{ b.count }}</button> <button @click="incC">C: {{ count }}</button> <button @click="incD">D: {{ d.count }}</button> </template>

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.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. ✅
  • Ccount 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:

JavaScript
1
2
3
4
import { ref } from 'vue' const count = ref(0) count.value++ // a write to the `value` property of the ref

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.

JavaScript
1
2
const state = ref({ user: { name: 'Ada' } }) state.value.user.name = 'Grace' // tracked: the inner object is reactive too

reactive() returns a Proxy of the object you gave it. No .value, which reads more naturally:

JavaScript
1
2
3
4
import { reactive } from 'vue' const form = reactive({ email: '', agreed: false }) form.email = 'ada@example.com' // tracked

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 need ref.
  • The proxy is the reactive thing, not your original object. reactive(obj) !== obj. Mutate obj directly 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:

JavaScript
1
2
3
4
5
6
7
import { reactive, toRefs, toRef } from 'vue' const state = reactive({ count: 0, name: 'Ada' }) const { count, name } = toRefs(state) // both are refs now count.value++ // writes back to state.count const justCount = toRef(state, 'count') // one property only

2. Replacing the object. Keep the proxy and mutate it, or use a ref when you genuinely need to swap the whole value:

JavaScript
1
2
const state = ref({ count: 0 }) state.value = { count: 1 } // fine — you're writing to `value`, which is tracked

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:

JavaScript
1
2
3
4
// ❌ takes a snapshot function useDouble(n) { return computed(() => n * 2) } // ✅ takes a source function useDouble(source) { return computed(() => unref(source) * 2) }

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:

JavaScript
1
2
3
4
5
watchEffect(async () => { const id = props.id // ✅ tracked await nextTick() console.log(state.count) // ❌ NOT tracked — runs after the sync pass })

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.

JavaScript
1
2
const items = ref([...]) const total = computed(() => items.value.reduce((n, i) => n + i.price, 0))

Two consequences that matter in real code:

  • A computed is not a watch. 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.value a hundred times in a template costs one calculation. That's the reason to prefer computed over a method call for derived values.

They look interchangeable and aren't:

JavaScript
1
2
3
4
5
// explicit source, lazy: does not run until `id` changes watch(id, (next, prev) => load(next)) // implicit sources, eager: runs immediately, re-runs when anything it READ changes watchEffect(() => load(id.value))

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.

JavaScript
watch(count, () => { /* DOM is already updated here */ }, { flush: 'post' })

  • 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 ref inside reactive is unwrapped. const s = reactive({ n: ref(0) }) then s.n is 0, not the ref. Convenient in templates, surprising in logic. Inside a plain array or Map it is not unwrapped.
  • Deep conversion has a cost. ref(tenThousandRows) proxies objects as they're accessed. For large, read-mostly payloads use shallowRef and replace the whole value: js const rows = shallowRef([]) rows.value = await fetchRows() // one write, no deep proxying
  • markRaw keeps 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 ref created 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] = x and arr.length = 0 both work in Vue 3 (this was a Vue 2 limitation people still carry around).

  • ref by default. It works for every type, it survives being passed around, and .value is a visible marker that this is reactive state. A codebase that uses ref everywhere is boringly consistent.
  • reactive for 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 reactive object without toRefs. Never reassign one.
  • Return refs from composables, and accept refs or getters as inputs.
  • computed for derived values, watchers for side effects. If you're assigning to a ref inside a watch, ask whether it should have been a computed.
  • Reach for shallowRef/shallowReactive/markRaw when deep reactivity is cost without benefit.

Runs right in your browser — poke at it and watch the concept react live.

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.

TaskCodeNotes
Reactive primitiveconst n = ref(0).value in JS, auto-unwrapped in templates
Reactive objectconst s = reactive({})no .value; never reassign
Shallow (big payload)shallowRef([])replace the whole value, no deep proxying
Destructure safelyconst { a } = toRefs(state)each property becomes a ref
One property as a reftoRef(state, 'a')writes flow back to state.a
Derived valuecomputed(() => a.value * 2)cached, lazy, no side effects
React to a changewatch(a, (next, prev) => {})lazy; gives you the old value
React to anything readwatchEffect(() => {})eager; sync reads only
After the DOM updateswatch(a, cb, { flush: 'post' })default flush is pre-render
Escape the proxytoRaw(s) / markRaw(obj)identity checks; opt out entirely
Accept ref or valueunref(source)composable-friendly inputs
JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { ref, reactive, computed, watch, toRefs, shallowRef } from 'vue' // values you REPLACE → ref const user = ref(null) user.value = await fetchUser() // objects you MUTATE → reactive const form = reactive({ email: '', agreed: false }) form.email = 'ada@example.com' // destructuring: keep the subscription const { email } = toRefs(form) // derived → computed (cached, no side effects) const valid = computed(() => email.value.includes('@') && form.agreed) // side effects → watch watch(valid, (isValid) => isValid && enableSubmit(), { flush: 'post' }) // large read-mostly data → shallowRef const rows = shallowRef([]) rows.value = await fetchRows()

  • 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.
  • ref for values you replace; reactive for objects you mutate and hold on to.
  • computed for derived data (cached, lazy, no side effects); watchers for side effects, with flush: 'post' when you need the DOM.
  • Deep reactivity is a default, not a law — shallowRef and markRaw exist 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:

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.