Skip to content
beginner Phase 3 · Reactive Refs and the Composition API

reactive for Objects

Wrap objects directly and mutate properties without dot value.

45m
0 problems
Topic Progress 0%

reactive() Returns a Proxy, Not Your Object

Calling reactive(obj) does not modify obj and does not return it. It returns a Proxy — a new object that forwards every operation to the original (the target) while intercepting reads and writes for tracking. Your code works with the proxy; the original stays pristine underneath.

import { reactive, toRaw, isReactive } from 'vue'

const state = reactive({ count: 0, user: { name: 'Ana' } })

state.count++                        // set trap → trigger
state.user.name = 'Bruno'            // deep: child object is proxied on access
console.log(isReactive(state))       // true
console.log(state === toRaw(state))  // false — proxy is not the target

The consequences of "proxy in front, target behind" are worth stating, because they explain several surprises:

  • Identity is the proxy's. reactive(obj) !== obj, and toRaw(state) gets the original back for libraries that must not see proxies (serializers, canvas engines). Vue caches proxies per target, so reactive(obj) === reactive(obj) — repeated calls are safe and return the same proxy. The template and devtools both display the proxy's view; equality checks in your code compare proxies when both sides went through reactive.
  • Every operation is preserved and intercepted. Method calls, in checks, iteration (for...of, Object.keys), property addition and deletion — all still work on the proxy, and all are tracked. Iteration tracks the set of keys (so adding a key re-renders whatever iterated), and each property read tracks that key alone.
  • Conversion is deep and lazy. Nested objects are wrapped the first time they are read through the proxy and cached thereafter, exactly like ref's object mode from ref for Primitive State. A thousand untouched rows pay nothing; touched ones become proxies on the way out.

Given that ref also produces deeply reactive proxies for object values, the honest question is when reactive earns its place. The answer from real codebases: local, shape-stable state that is never replaced wholesale — a multi-field form being edited in place, a UI-state object with a fixed set of keys, a small config bag. Its ergonomics read cleanly (form.email = '' versus form.value.email = ''), and since you are mutating fields rather than reassigning the variable, its main limitation never bites.

The moment state outgrows that description — fetched payloads swapped wholesale, arrays replaced by filtered results, anything that wants state.value = data — the limitations chapter ahead becomes the deciding factor, and the codebase convention is to switch to ref. Many teams keep both in play with one rule of thumb: ref by default, reactive only where the whole-object ergonomics are used and replacement is provably never needed. The pitfalls between here and that decision — destructured variables going dead, spreads producing inert copies — are what the next chapter catalogs.

A test that settles proxy-versus-target questions in five seconds: calling reactive twice on the same object returns the same proxy — the cache guarantees it — while the proxy itself never equals the target. In devtools, watch a value update through the proxy while the raw object stays unchanged underneath; the proxy is a view, never a transformation of your data. Serializers and third-party libraries should receive the target via toRaw, which is also why a class instance stored inside reactive state should be marked with markRaw before insertion rather than after.

The Three Hard Limits: Primitives, Replacement, and the Const Trap

reactive has three boundaries that are worth knowing cold, because each one produces a bug report at least once per quarter in a Vue codebase.

Limit 1: primitives are refused. reactive works by proxying an object's properties, so there is nothing to intercept on 42 or 'hello'. Vue says so in development:

const count = reactive(0)
// [Vue warn] value cannot be made reactive: 0

(The returned value in that case is the primitive itself, unchanged and unreactive — worse than a crash, because nothing warns at the use site.) Scalar state is ref's territory; this boundary is the clean division of labor between the two APIs.

Limit 2: wholesale replacement is not a thing. There is no state.value = newData — the variable is the proxy, so what would that even mean? The two instinctive attempts both fail differently:

const state = reactive({ items: [] })

state = { items: [1, 2, 3] }        // 1: rebinds the local variable — TS flags it (const),
                                     // and even with let the old proxy keeps the OLD target
Object.assign(state, newData)        // 2: the correct pattern — mutates through the proxy,
                                     // each assigned key triggering normally
state.items = fetchedItems           // also correct: a property write, not a rebind

That second failure mode is the nasty one: with let state, reassignment changes only your local binding while every template and watcher still holds the old proxy — the UI is now frozen on stale data with zero errors anywhere. The working idioms for "replace everything" are Object.assign(state, data) (when keys are stable), per-property assignment (state.items = data.items), or — when the shape genuinely changes between fetches — the state should have been a ref all along.

Limit 3: the const trap makes replacement feel impossible by accident. Because reactive returns the proxy and the binding is a const, the mental model hardens into "state can only be patched". That is fine for form state and wrong for server data: a fetched entity replaced by a refresh, a list swapped by a filter, a selection object re-pointed to another item. The practical decision rule:

Use reactive when the variable's identity is fixed for the component's lifetime and only its properties change. Use ref the moment the value itself might be replaced.

Mutation-style APIs you will use daily with reactive: Object.assign for bulk updates, delete state.error (tracked — iteration dependencies update), direct index assignment on reactive arrays (tracked in Vue 3), and spread-free merges via helpers like Object.assign({}, state, patch) only when you want a new object — at which point you are in ref territory. There is also markRaw if a reactive object must hold a third-party instance (chart, editor) that should never be proxied.

The idioms that replace replacement are worth collecting, because they are what you actually type day to day. Bulk refresh assigns every key through the proxy; a single-section update assigns just that property; a field reset writes null or the initial value; key removal goes through delete — all tracked, all routed through the traps. When a helper receives reactive state and updates several fields, pass the proxy itself and mutate inside it; returning a patch object for the caller to apply doubles the API surface for no reactive benefit.

Knowing the limits is half the story; the另一半 — a destructuring habit that silently turns reactive variables into dead snapshots — is pervasive enough to deserve its own chapter, and it is where toRefs earns the word "essential".

The Destructuring Trap, and toRefs: The Fix That Keeps the Link

Here is the bug that looks like nothing and wastes an afternoon. A component's setup holds a reactive form, someone destructures it "for readability", and the template stops updating — with no warning, no error, and a perfectly valid-looking console.

const form = reactive({ email: '', valid: false })
const { email, valid } = form   // plain string and boolean — SNAPSHOTS, cut from the source

function check() {
  valid.value = true            // TypeError: valid is a boolean, not a ref — or worse,
}                                // valid = true (no error, no reactivity)

Destructuring reads each property once and stores the resulting primitive. Reactivity lives in the proxy's getter, and a snapshot never calls the getter again. email is now an ordinary string frozen at setup time; form.email keeps updating, email does not, and any template reading the destructured name is a static picture of the initial state. The same trap closes on anything that reads properties out of the proxy early: spreading ({ ...form }), Object.assign({}, form), and passing form.email into helpers — all read through the getter once and keep the plain result.

toRefs is the bridge that preserves the link, converting each own enumerable property of a reactive object into a ref pointing back at it:

import { reactive, toRefs } from 'vue'

const form = reactive({ email: '', valid: false })
const { email, valid } = toRefs(form)   // Ref<string>, Ref<boolean> — linked

valid.value = true        // writes through the ref → form.valid updates → template re-renders
form.valid = false        // and the reverse direction is the same ref

Every ref in the result reads and writes the corresponding property of the source, in both directions, forever. That makes toRefs the sanctioned way to hand pieces of reactive state around — into composables, across a use boundary, or simply into a template that prefers short names. The Pinia flavor of the same idea is storeToRefs(store), which does the equivalent for a store's state (and getters), and is the answer to the common "my destructured store stopped updating" report.

Two refinements that prevent over-application. First, toRefs is shallow in exactly the way you want: top-level properties become refs, but nested objects remain the reactive objects they were — toRefs(form).address is a ref to the reactive address object, not a re-wrapped copy. Second, prefer destructuring functions from reactive state freely: methods are values, not reactive data, and pulling them out (const { submit } = form — unusual, but legal for method-holding objects) carries no tracking to lose. The rule generalizes cleanly: destructure refs (they are containers), never raw values out of reactive objects; use toRefs when you want the convenience of destructuring with the link intact.

This trap — plus its cousins, spreading that produces inert copies and unwrapping confusion about where .value is needed — makes up the majority of day-to-day Vue reactivity bugs. The next topic, Common Reactivity Pitfalls, collects the full catalog with the diagnosis pattern for each.

One verification exercise worth doing once in a live app: open devtools, select the component, and watch the state pane while you mutate an email field through the proxy and then through a destructured copy of it. The pane's value moves for the first and stands still for the second — a thirty-second demonstration that reactivity is a property of the access path, not of the data itself. Developers who have seen that asymmetry once stop writing the bug without any linter prompting them.

Quiz

1. What does `const state = reactive({ n: 1 })` return, and what does `toRaw(state)` give back?

Question 1 options

2. Why does `const { email } = form` (form is reactive) break updates, and what is the fix?

Question 2 options

3. After `let state = reactive({ items: [] })`, a fetch resolves and you run `state = { items: data }`. What is the effect?

Question 3 options

4. What happens when you call reactive() on a primitive like 5?

Question 4 options

Flashcards

Question

What does reactive() return, and how does it relate to the original object?

Answer

A cached Proxy over the target — not the object itself (proxy !== raw, toRaw gets it back). Property reads/writes, iteration, in-checks, add/delete are all intercepted and tracked; nested objects are wrapped lazily and deeply; repeated reactive() calls on one target return the same proxy.

Question

What are reactive's three hard limits?

Answer

1) Primitives are refused — dev warning 'value cannot be made reactive' and the unreactive value is returned (use ref). 2) No wholesale replacement — there is no .value channel; use Object.assign or per-property writes (or a ref). 3) The const-binding trap: reassigning a let variable only rebinds the local pointer while the UI keeps the old proxy, freezing silently.

Question

Why does destructuring a reactive object lose reactivity, and what is the fix?

Answer

Destructuring reads each property once through the proxy getter and stores the resulting primitive — the variable never touches the proxy again, so it's a frozen snapshot with no error anywhere. Fix: toRefs(obj), which turns each own property into a ref linked in both directions (Pinia equivalent: storeToRefs(store)). Spreading has the same effect for the same reason.

Question

reactive or ref — what is the decision rule?

Answer

reactive: shape-stable local state whose variable identity never changes and only properties mutate (forms, fixed UI-state bags). ref: everything replaceable — fetched payloads, swapped arrays, anything that wants state.value = data. Teams commonly default to ref and use reactive only where the .value-free ergonomics are actively used.

Question

Which operations on a reactive object are tracked, including the non-obvious ones?

Answer

Property reads/writes, nested deep mutations, add/delete of keys (iteration dependencies re-render), for...of/Object.keys iteration, and — for reactive arrays — index assignment. Untracked: anything done on a destructured/spread copy or on toRaw(state), because those bypass the proxy boundary entirely.

Revision Notes

Key Takeaways

  • 1. reactive returns a cached deep Proxy — mutations, adds, deletes, and iteration are all tracked at the proxy boundary; toRaw crosses back to the target
  • 2. Primitives are refused with a dev warning; wholesale replacement has no channel — the variable is the proxy, so state.value-style swaps don't exist
  • 3. Destructuring (and spreading) reads properties once and keeps inert copies — the silent 'UI stopped updating' classic; toRefs/storeToRefs restore the link
  • 4. Reassigning a let-bound reactive variable rebinds only the local pointer — templates keep the old proxy and render stale data with no error
  • 5. The pragmatic rule: ref by default for replaceable state; reactive only for shape-stable, in-place-mutated local state

Interview Tips

  • • "What does reactive() return?" — a Proxy, not the object; mention caching (same target → same proxy), lazy deep wrapping, and toRaw for the target — this proves you know the mechanism
  • • "Why did my template stop updating after destructuring?" — getter called once, snapshot kept; give the toRefs fix and the storeToRefs variant for Pinia — a very common real-world debugging story
  • • "ref or reactive?" — state the replacement axis: ref supports wholesale swaps, reactive doesn't; add the form-state niche where reactive ergonomics win
  • • "How do you replace a reactive object's contents?" — Object.assign or per-property writes; explain why reassignment fails (pointer vs proxy) — separates candidates who've shipped from those who've read

Cheat Sheet

reactive for Objects

Mental model: reactive(obj) → cached Proxy over obj (deep, lazy). Mutate through the proxy; toRaw() returns the target; isReactive() checks the proxy side.

const form = reactive({ email: '', valid: false })
form.email = 'a@b.c'                 // tracked
Object.assign(form, serverData)      // bulk update through the proxy
delete form.error                    // tracked
const { email } = toRefs(form)       // linked refs, both directions

Limits:

Limit Symptom Fix
primitives refused dev warning, unreactive value returned use ref
no wholesale replacement state = data rebinds local only — UI frozen on old proxy, silent Object.assign / per-property writes / use ref for replaceable state
destructuring cuts the link { email } = form is a frozen snapshot toRefs(form) / storeToRefs(store)

Destructure rule: refs are containers — destructure freely; values out of reactive objects (destructuring, spreading, early reads) become inert copies — use toRefs, or read through the proxy at use time. Nested objects inside toRefs results stay reactive objects (toRefs is top-level only).

Choose: shape-stable local state, mutated in place → reactive · anything ever replaced wholesale → ref.

Continue Learning

Before You Start

Related Reading