skip to content

Ref vs Reactive

ref() wraps any value behind .value while reactive() returns a deep Proxy of an object, and unwrapping differs in templates, objects and arrays. Interviewers ask why the docs default to ref.

part ofVue.jsoverview, primer and where to startread it →
on this pageshow

explore

questions

4

In Vue 3, what is the difference between ref() and reactive(), and why do the Vue docs recommend ref() as the primary API?

level: juniorimportance: must knowfreq 84%

answer

  1. one wraps, one proxies
  2. where the tracking hook lives
  3. what reactive() cannot hold
  4. keeping the same reference

basics

~20 s

ref() wraps any value in an object whose .value getter tracks and setter triggers; reactive() returns a deep Proxy of an object itself. The docs prefer ref() because reactive() rejects primitives, cannot be replaced wholesale, and loses tracking when destructured.

solid answer

~40 s

`ref()` wraps any value, primitive or object, in an object with a `.value` property: the getter tracks reads, the setter triggers updates, and an object value is made deeply reactive with `reactive()` internally. `reactive()` returns a deep `Proxy` of the object itself, so you use properties directly with no `.value`. The Vue docs recommend `ref()` as the primary API because `reactive()` has three limitations: it only accepts object types, you cannot replace the whole object without losing the reactive connection, and destructuring or passing out a primitive property hands over a plain value with no tracking. A ref can be reassigned with `.value = next` and passed into functions while staying live; the price is writing `.value` in script, since templates unwrap top-level refs for you.

code

vue · 18 lines
vue
<script setup lang="ts">
import { ref, reactive } from 'vue'

const count = ref(0)                       // primitive: needs a ref
const user = ref({ name: 'Ada', tags: [] as string[] })
const filters = reactive({ query: '', page: 1 })

function bump() {
  count.value++                            // .value in script
  user.value.tags.push('vue')              // nested write is tracked
  user.value = { name: 'Grace', tags: [] } // whole replacement is fine
  filters.page++                           // no .value on reactive()
}
</script>

<template>
  <button @click="bump">{{ count }} {{ user.name }} {{ filters.page }}</button>
</template>

go deeper

for a junior

Know that ref() takes any value behind .value, reactive() proxies an object, and that templates unwrap top-level refs. Be able to name the three reactive() limitations.

for a middle

Explain why the wrapper exists at all: plain variables cannot be intercepted, while a .value getter and setter or a Proxy trap can track and trigger. Mention that ref() calls reactive() for object values.

for a senior

Connect the limitations to real bugs you have seen: a reassigned reactive() form, a primitive passed into a helper that silently stopped updating. Show that you choose ref() where replacement is possible.

for a principal

Frame the choice as a team convention: one declaration rule that never breaks on replacement versus the ergonomics of grouped reactive() objects, and how you keep a codebase consistent.

## Two ways to declare reactive state Vue 3's Composition API gives you two core functions for declaring state the renderer can follow: **`ref()`** and **`reactive()`**. Both come from the `vue` package, both are deeply reactive by default, and both feed the same dependency-tracking system: when a component renders, Vue records which reactive values it read, and when one of them changes it schedules that component to re-render. What differs is *what* they wrap and *how* you read and write through them. ## How `ref()` works `ref()` accepts **any value** - a number, a string, a boolean, `null`, an object, an array - and returns a small wrapper object with one `.value` property. - Reading `.value` runs a getter that **tracks** the read against whatever effect is running (a component render, a `computed`, a watcher). - Writing `.value` runs a setter that compares the new value with the old one and, if it changed, **triggers** the effects that read it. - If the value is an object, `ref()` passes it through `reactive()` internally, so `profile.value.address.city = 'Oslo'` is detected as well as `profile.value = otherProfile`. The wrapper exists because of a JavaScript limitation: a plain variable cannot be intercepted. `let count = 0; count++` is invisible to any library. Property access on an object *can* be intercepted, so Vue puts the value behind a property. That also means a ref can be **passed around** - into a function, a composable, a child component - and still carry its live value and its connection to the tracking system. In templates you do not write `.value`: refs that are top-level bindings of the template's render context are unwrapped for you, though a ref nested inside a plain object is not. ## How `reactive()` works `reactive()` takes an **object** - a plain object, an array, a `Map` or a `Set` - and returns a JavaScript `Proxy` of it. There is no `.value`: you read and write properties directly, `state.count++`, and the proxy tracks and triggers per property. - The conversion is **deep**: nested objects are wrapped in their own proxies lazily, when you access them. - A ref stored as a property of a reactive object is unwrapped on access, so `state.count` returns the number, not the ref. - Only the proxy is reactive: writing to the original object behind Vue's back triggers nothing. ## Side by side | | `ref()` | `reactive()` | |---|---|---| | Accepts | any value, including primitives | object types only | | Access in script | `x.value` | `state.prop` | | Access in template | unwrapped at top level | `state.prop` | | Replace the whole value | `x.value = next` | not possible without losing reactivity | | Nested objects | made reactive via `reactive()` | deeply reactive | | Hand to a function | the ref keeps its connection | a primitive property arrives as a plain value | ## Why the docs make `ref()` the default The Vue guide lists three limitations of `reactive()` and, because of them, recommends `ref()` as the primary API for declaring reactive state: 1. **Limited value types.** `reactive()` only works on object types. `reactive(5)` simply returns `5` - in development Vue logs a warning that the value cannot be made reactive - and nothing is tracked. 2. **No replacing the whole object.** Tracking hangs off the proxy you received. Assign a fresh `reactive({...})` to the same variable and everything that subscribed to the first proxy stays attached to it, while the new one has no subscribers. 3. **Not destructure-friendly.** Pulling a primitive property out into a local variable, or passing `state.count` into a function, hands over a plain value with no reactive connection. `ref()` avoids all three: it accepts anything, `.value = next` replaces the value while keeping every subscriber, and the ref object itself is what you pass around. The cost is `.value` in script code, which many teams accept in exchange for one rule that works everywhere. ## When `reactive()` is still reasonable `reactive()` is not deprecated. It reads well for a group of fields that always travel together and are never swapped out wholesale - a local form model mutated field by field, for example - because `form.email` is shorter than `form.value.email`. A practical rule: - a single primitive, or anything you may reassign later: `ref()`; - a fixed-shape object you only ever mutate in place: either works; - whichever you pick for that second case, consistency inside one codebase matters more than the choice itself. Either way, remember that a ref holding an object still creates a reactive proxy for that object: choosing `ref()` changes how you access and replace state, not whether proxies exist.

  • In Vue 3, is an object stored in ref() deeply reactive?
    Yes. When a ref's value is an object, `ref()` converts it with `reactive()`, so `user.value.address.city = 'Oslo'` triggers updates just like replacing `user.value`. The same happens when you later assign a new object to `.value`. Opting out of the deep conversion is a separate API choice (`shallowRef()`), not the default.
  • What does Vue 3 do if you call reactive() on a primitive such as a number?
    It returns the primitive unchanged and, in development builds, logs a warning that the value cannot be made reactive. Nothing is tracked, so later assignments to that variable never update the UI. That is limitation one in the docs, and the reason primitives go in `ref()`.
  • Why can't Vue make a plain `let count = 0` reactive, so that .value is unnecessary?
    JavaScript offers no hook for reading or assigning a local variable; only property access on an object can be intercepted, with a getter/setter or a Proxy. A ref puts the value behind the `.value` property so Vue can track the read and trigger on the write. Templates hide `.value` for top-level refs, but script code has to write it.

A ref is a labelled box with one slot: swap what is inside and everyone watching the box sees the swap. reactive() is a filing cabinet with every drawer monitored; roll in a new cabinet and the people watching the old one never notice.

saying these in an interview costs you the question

  • ref() is only for primitives and reactive() is required for objects.
  • An object stored in ref() is shallow, so nested property changes are missed.
  • The docs prefer ref() because reactive() is deprecated.
  • You must write .value in the template as well as in script.
  • reactive() can be reassigned with a new object and the UI follows it.
open as a page

In Vue 3, where are refs unwrapped automatically, and where must you still write .value - templates, reactive objects, arrays or Maps?

level: middleimportance: should knowfreq 55%

basics

~20 s

Vue 3 unwraps top-level refs in templates and refs stored as properties of a deep reactive object. It does not unwrap refs held in reactive arrays by index, in Map or Set collections, or nested inside plain objects.

open as a page

A Vue 3 `<script setup>` profile form declares `let profile = reactive({ name: '', email: '' })`, then runs `profile = reactive(await loadProfile())`; why do the fields stay empty, and how do you fix it?

level: seniorimportance: should knowfreq 50%

basics

~20 s

Reassigning the variable is invisible to Vue: the render tracked properties of the first proxy, and the new proxy has no subscribers. Mutate the existing object with Object.assign(profile, data), or hold the form in a ref and assign profile.value.

open as a page

As a Vue 3 lead, would you standardise a codebase on ref() only, and what does a ref-only convention cost?

level: principalimportance: nice to knowfreq 30%

basics

~20 s

Usually yes: ref-only gives one declaration rule that survives replacement and passing values around. It costs .value noise, forgotten-.value bugs in script, heavier grouped form state, and it does not remove proxies, since object values still go through reactive().

open as a page