In Vue 3, where are refs unwrapped automatically, and where must you still write .value - templates, reactive objects, arrays or Maps?
answer
- only certain containers unwrap
- top level of render context
- get unwraps, set writes through
- integer index is the exception
basics
~20 sVue 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.
solid answer
~40 sIn a template, only refs that are top-level bindings of the render context are unwrapped: `{{ count + 1 }}` works, but with `const object = { id: ref(1) }` the expression `{{ object.id + 1 }}` renders `[object Object]1`. The one convenience is that a ref which is the final value of a `{{ }}` interpolation is displayed unwrapped. Inside a deep `reactive()` object, reading a property that holds a ref returns its value, and assigning a plain value writes through into that ref; assigning a different ref replaces the link. There is no unwrapping for a ref stored in a reactive array and read by index, or stored in a `Map` or `Set`: `books[0].value` and `map.get('count').value` are required. A shallow reactive object does not unwrap either.
code
vue · 14 lines<script setup lang="ts">
import { ref } from 'vue'
const count = ref(0)
const object = { id: ref(1) }
const { id } = object
</script>
<template>
<p>{{ count + 1 }}</p> <!-- 1: top-level ref unwrapped -->
<p>{{ object.id + 1 }}</p> <!-- [object Object]1: nested ref -->
<p>{{ object.id }}</p> <!-- 1: final interpolation value -->
<p>{{ id + 1 }}</p> <!-- 2: destructured to top level -->
</template>go deeper
Remember the short version: templates unwrap top-level refs, reactive objects unwrap ref properties, arrays and Maps do not.
Explain the read and write behaviour of a ref inside a reactive object, including write-through and replacement, and why the interpolation convenience does not apply to expressions or bound attributes.
Recognise [object Object] output or a missing .value as a container problem, and steer designs away from storing refs in reactive arrays or maps.
Treat unwrapping rules as a readability cost: a convention that keeps refs at the top level and plain values inside containers removes a whole class of review comments.
## Why unwrapping exists A ref keeps its value behind `.value`. Writing `.value` everywhere would be noisy, so Vue 3 **unwraps** refs automatically in a few specific places - and only there. Knowing the exact list is what prevents a template rendering `[object Object]1`, or script code calling a string method on a ref object. There are three contexts to know: the template, properties of a reactive object, and elements of arrays and collections. ## Templates: top-level bindings only In a `<script setup>` component every top-level binding is exposed to the template, and refs among them are unwrapped: with `const count = ref(0)`, `{{ count + 1 }}` renders `1`. The rule is **top level of the render context**: - `const object = { id: ref(1) }` - `object` is a plain object, so `object.id` is still a ref inside an expression. `{{ object.id + 1 }}` renders `[object Object]1`. - As a display convenience, a ref that is the **final value** of a text interpolation is unwrapped by the interpolation's string conversion, so `{{ object.id }}` renders `1`. It is equivalent to `{{ object.id.value }}` and happens only for `{{ }}` text: a bound attribute such as `:title="object.id"` receives the ref object. - Fixes: destructure the ref into a top-level binding (`const { id } = object`), or make the container `reactive()` so that property access unwraps. A ref holding an object is unwrapped at the top level too, so `{{ user.name }}` works for `const user = ref({ name: 'Ada' })`. ## Reactive objects: unwrap on read, write through on assign When a ref is stored as a property of a deep `reactive()` object, the proxy treats it like a normal property: 1. **Read** - `state.count` returns `count.value`, not the ref. 2. **Assign a plain value** - `state.count = 1` writes into the existing ref, so `count.value` becomes `1` as well. 3. **Assign another ref** - `state.count = otherRef` replaces the link; the original `count` ref is disconnected from `state`. 4. **Depth** - unwrapping applies at any depth of a deep reactive object, and therefore also inside an object held by a ref, because a ref converts its object value with `reactive()`. `readonly()` unwraps the same way and returns the unwrapped values as readonly. A **shallow** reactive object is the exception: its properties are returned as stored, refs included. ## Arrays and collections: no unwrapping The rule flips for containers of elements: - A ref inside a reactive **array**, read by integer index, is returned as the ref: `books[0].value`. - A ref stored in a reactive **`Map`** or **`Set`** is not unwrapped by `get()` or iteration: `map.get('count').value`. The implementation says it plainly: the proxy's get handler unwraps refs *except* for an array accessed with an integer key, and the set handler likewise only writes through into an existing ref when the target is not an array element. Collections go through separate instrumented methods that return stored values without unwrapping them. ```ts import { ref, reactive } from 'vue' const count = ref(0) const state = reactive({ count }) state.count // 0 - unwrapped state.count = 5 // count.value === 5 const books = reactive([ref('Vue 3 Guide')]) books[0].value // array element: .value needed const map = reactive(new Map([['count', ref(0)]])) map.get('count').value // collection: .value needed ``` ## The rules in one table | Where the ref sits | Unwrapped? | What you write | |---|---|---| | Top-level binding used in a template | yes | `{{ count + 1 }}` | | Plain object property used in a template expression | no | `{{ object.id.value + 1 }}` or destructure | | Final value of a `{{ }}` interpolation | yes, display only | `{{ object.id }}` | | Property of a deep `reactive()` or `readonly()` object | yes | `state.count` | | Property of a shallow reactive object | no | `state.count.value` | | Element of a reactive array, by index | no | `books[0].value` | | Value in a reactive `Map` or `Set` | no | `map.get(k).value` | | Script code, a ref variable | no | `count.value` | ## Practical consequences - Do not put refs into reactive arrays or maps unless you want to keep the wrapper; store plain values and let the container's own reactivity track them. - Code that moves a ref from a reactive object into a plain object (or vice versa) changes whether `.value` is needed at the use site - a common source of `[object Object]` in templates. - When reading unfamiliar code, check the **container**, not just the value: the same ref reads differently depending on where it sits.
- In Vue 3, what happens when you assign a different ref to a reactive object's property that already holds a ref?The new ref replaces the old one as the property's value. From then on `state.count` reads and writes `otherRef`, and the original ref keeps its last value but is no longer connected to `state`. Assigning a plain value, by contrast, writes through into whichever ref currently sits there.
- Why does `{{ object.id }}` render 1 while `{{ object.id + 1 }}` renders [object Object]1 in a Vue 3 template?The expression `object.id + 1` is evaluated as ordinary JavaScript, and `object` is a plain object, so `object.id` is the ref and string concatenation stringifies it. When the ref is the whole interpolation result, the text conversion step checks for a ref and displays its value; that convenience applies only to `{{ }}` output.
saying these in an interview costs you the question
- Refs are unwrapped everywhere in a template, however deeply they are nested.
- A reactive array unwraps refs stored as its elements, like a reactive object does.
- Assigning a number to a reactive property that holds a ref overwrites the ref.
- map.get(key) on a reactive Map returns the ref's value, not the ref.
- If {{ object.id }} renders correctly, object.id + 1 will also work.