Vue.js 20 🟢 Ref Unwrapping Rules in Templates vs Script Blocks
A ref is a reactive object with a .value property. In the template, the .value is invisible: {{ count }} reads the ref’s value, and @click="count++" writes to it. In the script, the .value is required: count.value is the only way to read or write the ref. This asymmetry is deliberate, but it has edge cases. The unwrapping applies only to top-level refs in a template. It does not apply inside arrays, plain objects, or Maps. It does not apply in the script at all, except in a few specific contexts. Understanding the rules is understanding where the .value is required and where it is not.
Key point: The template auto-unwraps top-level refs. The script does not. A ref nested inside a plain object, an array, or a Map is not unwrapped in the template. A ref used inside a reactive() object is unwrapped because reactive() unwraps refs at the top level. A ref inside a computed, a watch callback, or a lifecycle hook is accessed with .value in the script. The rules are consistent once the contexts are distinguished.
Why the unwrapping rules exist
The script problem. The script is plain JavaScript. A ref is an object. Reading a variable that holds a ref gives the ref object, not the value. The .value property is the only way to get the value. If the script auto-unwrapped refs, the behavior would be unpredictable: a function that receives a ref would not know whether it is receiving the ref or the value.
The template problem. The template is a compiled render function. The compiler knows which bindings are refs and accesses .value automatically. The template author writes count instead of count.value, which is more readable. The compiler does the work.
The consistency problem. The unwrapping rules are not arbitrary. They follow from what the template compiler can know. A top-level binding in the <script setup> block is either a ref or a non-ref. The compiler can check the binding and add .value if it is a ref. A property of an object, or an element of an array, is not known at compile time. The compiler cannot check whether state.count is a ref, because state could be anything. So the template does not unwrap nested refs.
The reactive() exception. reactive() unwraps refs at the top level of the object. When a ref is assigned to a property of a reactive object, the property access returns the value, not the ref. This is a deliberate exception: the reactive object’s proxy unwraps the ref so that the object behaves like a plain object with the ref’s value. This makes it possible to mix refs and plain values in a reactive object without the .value leaking.
The composable problem. A composable returns refs. The component that uses the composable destructures the refs and uses them in the template and the script. The template auto-unwraps. The script requires .value. The composable’s contract is that it returns refs, and the caller uses them accordingly.
a. Unwrapping in the template
A top-level ref in the template is auto-unwrapped.
<script setup>
import { ref, computed } from 'vue'
const count = ref(0)
const name = ref('Alice')
const doubled = computed(() => count.value * 2)
</script>
<template>
<p>{{ count }}</p> <!-- count.value -->
<p>{{ name }}</p> <!-- name.value -->
<p>{{ doubled }}</p> <!-- doubled.value -->
<button @click="count++">+</button> <!-- count.value++ -->
</template>
Every top-level ref is unwrapped in the template. The {{ count }} reads count.value. The @click="count++" writes count.value++. The computed is also a ref, and it is unwrapped the same way.
The unwrapping applies to the template only. The <script setup> block is plain JavaScript, and the .value is required.
<script setup>
import { ref } from 'vue'
const count = ref(0)
console.log(count) // Ref object
console.log(count.value) // 0
count.value++ // write
</script>
The console.log(count) prints the ref object, not the value. The count.value++ writes to the ref. The template’s {{ count }} reads the value, but the script’s count is the ref object.
A ref used in a template expression is unwrapped, but only at the top level.
<script setup>
import { ref } from 'vue'
const state = {
count: ref(0)
}
</script>
<template>
<p>{{ state.count }}</p> <!-- Ref object -->
<p>{{ state.count.value }}</p> <!-- 0 -->
</template>
The state.count is a ref, but it is not a top-level binding. The template does not unwrap it. The .value must be used explicitly.
An array of refs is not unwrapped.
<script setup>
import { ref } from 'vue'
const items = [ref(1), ref(2), ref(3)]
</script>
<template>
<p>{{ items[0] }}</p> <!-- Ref object -->
<p>{{ items[0].value }}</p> <!-- 1 -->
</template>
The items[0] is a ref inside an array. The template does not unwrap it. The .value is required.
A ref inside a Map is not unwrapped.
<script setup>
import { ref } from 'vue'
const map = new Map([['count', ref(0)]])
</script>
<template>
<p>{{ map.get('count') }}</p> <!-- Ref object -->
<p>{{ map.get('count').value }}</p> <!-- 0 -->
</template>
The same rule applies. The ref is not top-level, and the template does not unwrap it.
The unwrapping applies to the ref itself, not to the object it holds. A ref that holds an object is unwrapped to the object.
<script setup>
import { ref } from 'vue'
const user = ref({ name: 'Alice', age: 30 })
</script>
<template>
<p>{{ user.name }}</p> <!-- user.value.name -->
<p>{{ user.age }}</p> <!-- user.value.age -->
</template>
The user is a ref. The template unwraps it to the object. The .name and .age are the object’s properties. The .value is not needed because the unwrapping happens before the property access.
A ref inside a v-for is unwrapped if it is the item.
<script setup>
import { ref } from 'vue'
const items = ref([1, 2, 3])
</script>
<template>
<ul>
<li v-for="item in items" :key="item">{{ item }}</li>
</ul>
</template>
The items is a ref. The v-for iterates over items.value. The item is a plain number, not a ref. The unwrapping happens at the items level.
If the items are refs, they are not unwrapped in the loop.
<script setup>
import { ref } from 'vue'
const items = ref([ref(1), ref(2), ref(3)])
</script>
<template>
<ul>
<li v-for="item in items" :key="item.value">{{ item.value }}</li>
</ul>
</template>
The item is a ref. The .value is required.
b. Unwrapping in the script and reactive()
In the script, the .value is required for a ref. There is no auto-unwrapping.
<script setup>
import { ref, computed, watch } from 'vue'
const count = ref(0)
const doubled = computed(() => count.value * 2)
watch(count, (newValue) => {
console.log(newValue) // the value, not the ref
})
function increment() {
count.value++
}
</script>
The computed callback reads count.value. The watch callback receives the new value, not the ref—watch unwraps the ref before calling the callback. The increment function writes count.value.
The watch and watchEffect functions unwrap refs before passing them to the callback. The computed getter does not; it receives the ref and must use .value.
A ref inside a reactive() object is unwrapped at the top level.
<script setup>
import { ref, reactive } from 'vue'
const count = ref(0)
const state = reactive({ count })
console.log(state.count) // 0, not the ref
state.count = 10 // updates the ref
console.log(count.value) // 10
The reactive() proxy unwraps the ref when it is assigned to a property. The state.count is the value, not the ref. Writing to state.count writes to the ref. The ref and the reactive property are linked.
The ref is unwrapped only at the top level of the reactive object. A ref nested inside an object inside the reactive object is not unwrapped.
<script setup>
import { ref, reactive } from 'vue'
const state = reactive({
count: ref(0),
nested: {
count: ref(1)
}
})
console.log(state.count) // 0 (unwrapped)
console.log(state.nested.count) // Ref object (not unwrapped)
The top-level count is unwrapped. The nested.count is not. The reactive() proxy only unwraps refs at the top level of the object it wraps.
An array of refs inside a reactive object is not unwrapped.
<script setup>
import { ref, reactive } from 'vue'
const state = reactive({
items: [ref(1), ref(2), ref(3)]
})
console.log(state.items[0]) // Ref object
The array elements are not top-level properties. The refs are not unwrapped.
A ref assigned to a reactive property after the object is created is also unwrapped.
<script setup>
import { ref, reactive } from 'vue'
const state = reactive({ count: 0 })
state.count = ref(10)
console.log(state.count) // 10
The assignment unwraps the ref. The property holds the value, and the ref is linked to the property.
The toRefs() function creates refs from a reactive object. The refs are linked to the object’s properties.
<script setup>
import { reactive, toRefs } from 'vue'
const state = reactive({ count: 0, name: 'Alice' })
const { count, name } = toRefs(state)
console.log(count.value) // 0
count.value = 10 // updates state.count
The toRefs refs are refs. In the script, .value is required. In the template, they are auto-unwrapped because they are top-level bindings.
c. The edge cases and the $ prefix
The unwrapping rules have a few edge cases that are worth knowing.
A ref in a v-for with an index. The item is unwrapped if the array is a ref and the item is a plain value. The item is not unwrapped if the array elements are refs.
A ref as a prop. When a ref is passed as a prop to a child component, it is passed as the ref object, not the value. The child receives the ref and must use .value in its script.
<!-- Parent -->
<script setup>
import { ref } from 'vue'
import Child from './Child.vue'
const count = ref(0)
</script>
<template>
<Child :count="count" />
</template>
<!-- Child -->
<script setup>
const props = defineProps(['count'])
console.log(props.count) // Ref object
</script>
The props.count is the ref object. The child’s template can access props.count and the template auto-unwraps it because props.count is a top-level binding in the template context. But the child’s script must use props.count.value.
A ref returned from a composable. The composable returns a ref. The component destructures it and uses it. The template auto-unwraps. The script requires .value.
export function useCounter() {
const count = ref(0)
const increment = () => count.value++
return { count, increment }
}
<script setup>
import { useCounter } from './composables/useCounter'
const { count, increment } = useCounter()
console.log(count) // Ref object
console.log(count.value) // 0
</script>
<template>
<p>{{ count }}</p> <!-- auto-unwrapped -->
</template>
The $ prefix. In the Options API, the $ prefix is used for built-in properties like $refs, $emit, and $attrs. In the Composition API, the $ prefix is not used for refs. There is no $count. The $ prefix is reserved for the template’s built-in variables like $event and $slots.
The template ref. A template ref is a ref that holds a DOM element or a component instance. It is created with ref(null) and assigned to the ref attribute.
<script setup>
import { ref, onMounted } from 'vue'
const inputRef = ref(null)
onMounted(() => {
inputRef.value.focus() // .value is the DOM element
})
</script>
<template>
<input ref="inputRef">
</template>
The inputRef is a ref. In the script, .value is the DOM element. In the template, the ref="inputRef" attribute binds the ref to the element. The template does not unwrap the ref; it uses the ref’s name.
A ref that holds a component instance is the same. The ref attribute on the component assigns the component instance to the ref.
Complete Example Session
<!-- ============================================ -->
<!-- PART 1: TOP-LEVEL REF IN TEMPLATE -->
<!-- ============================================ -->
<script setup>
import { ref } from 'vue'
const count = ref(0)
</script>
<template>
<p>{{ count }}</p> <!-- unwrapped -->
</template>
<!-- ============================================ -->
<!-- PART 2: REF IN SCRIPT -->
<!-- ============================================ -->
<script setup>
import { ref } from 'vue'
const count = ref(0)
console.log(count) // Ref object
console.log(count.value) // 0
</script>
<!-- ============================================ -->
<!-- PART 3: NESTED REF IN OBJECT -->
<!-- ============================================ -->
<script setup>
import { ref } from 'vue'
const state = { count: ref(0) }
</script>
<template>
<p>{{ state.count.value }}</p> <!-- .value required -->
</template>
<!-- ============================================ -->
<!-- PART 4: REF IN ARRAY -->
<!-- ============================================ -->
<script setup>
import { ref } from 'vue'
const items = [ref(1), ref(2)]
</script>
<template>
<p>{{ items[0].value }}</p> <!-- .value required -->
</template>
<!-- ============================================ -->
<!-- PART 5: REACTIVE UNWRAPS TOP-LEVEL REF -->
<!-- ============================================ -->
<script setup>
import { ref, reactive } from 'vue'
const count = ref(0)
const state = reactive({ count })
</script>
<template>
<p>{{ state.count }}</p> <!-- unwrapped by reactive -->
</template>
<!-- ============================================ -->
<!-- PART 6: REACTIVE DOES NOT UNWRAP NESTED REF -->
<!-- ============================================ -->
<script setup>
import { ref, reactive } from 'vue'
const state = reactive({
nested: { count: ref(0) }
})
</script>
<template>
<p>{{ state.nested.count.value }}</p> <!-- .value required -->
</template>
<!-- ============================================ -->
<!-- PART 7: COMPUTED AND WATCH -->
<!-- ============================================ -->
<script setup>
import { ref, computed, watch } from 'vue'
const count = ref(0)
const doubled = computed(() => count.value * 2)
watch(count, (newVal) => {
console.log(newVal) // value, not ref
})
</script>
<!-- ============================================ -->
<!-- PART 8: REF AS PROP -->
<!-- ============================================ -->
<!-- Child -->
<script setup>
const props = defineProps(['count'])
console.log(props.count) // Ref object
console.log(props.count.value) // value
</script>
<template>
<p>{{ props.count }}</p> <!-- template unwraps -->
</template>
<!-- ============================================ -->
<!-- PART 9: TOREFS -->
<!-- ============================================ -->
<script setup>
import { reactive, toRefs } from 'vue'
const state = reactive({ count: 0 })
const { count } = toRefs(state)
console.log(count.value) // 0
</script>
<template>
<p>{{ count }}</p> <!-- unwrapped -->
</template>
<!-- ============================================ -->
<!-- PART 10: TEMPLATE REF -->
<!-- ============================================ -->
<script setup>
import { ref, onMounted } from 'vue'
const inputRef = ref(null)
onMounted(() => {
inputRef.value.focus()
})
</script>
<template>
<input ref="inputRef">
</template>
The ten parts covered a top-level ref in the template, a ref in the script, a nested ref in an object, a ref in an array, reactive() unwrapping a top-level ref, reactive() not unwrapping a nested ref, computed and watch, a ref as a prop, toRefs, and a template ref.
Quick Reference
Unwrapping Rules
| Context | Unwrapped |
|---|---|
| Template, top-level ref | Yes |
| Template, nested ref | No |
| Template, ref in array | No |
Template, ref in Map | No |
| Script | No |
reactive() top-level ref | Yes |
reactive() nested ref | No |
toRefs() ref | Yes (template only) |
watch callback | Yes |
computed getter | No |
Access Patterns
| Situation | Script | Template |
|---|---|---|
| Top-level ref | count.value | count |
| Nested in object | state.count.value | state.count.value |
| In array | items[0].value | items[0].value |
In reactive() | state.count | state.count |
In reactive() nested | state.nested.count.value | state.nested.count.value |
| Prop | props.count.value | props.count |
toRefs() | count.value | count |
Functions That Unwrap
| Function | Unwraps |
|---|---|
reactive() | Top-level refs |
watch() | The watched ref |
watchEffect() | All accessed refs |
| Template compiler | Top-level refs |
Functions That Do Not Unwrap
| Function | Behavior |
|---|---|
computed() getter | Receives refs |
| Plain object | Stores refs as-is |
| Array | Stores refs as-is |
Map | Stores refs as-is |
Best Practices
✅ Do This:
<!-- Use top-level refs for template access -->
<script setup>
import { ref } from 'vue'
const count = ref(0)
</script>
<template>
<p>{{ count }}</p> <!-- ✅ -->
</template>
<!-- Use .value in the script -->
count.value++ <!-- ✅ -->
<!-- Use reactive for objects with refs -->
const state = reactive({ count: ref(0) })
console.log(state.count) // 0 <!-- ✅ -->
<!-- Use toRefs for destructuring -->
const { count } = toRefs(state) <!-- ✅ -->
<!-- Use .value for props that are refs -->
props.count.value <!-- ✅ -->
<!-- Use .value for template refs -->
inputRef.value.focus() <!-- ✅ -->
❌ Don’t Do This:
<!-- Don't use .value in the template for top-level refs -->
{{ count.value }} // unnecessary <!-- ⚠️ -->
<!-- Don't forget .value in the script -->
count++ // does not update <!-- ❌ -->
<!-- Don't expect nested refs to be unwrapped -->
const state = { count: ref(0) }
{{ state.count }} // Ref object <!-- ❌ -->
<!-- Don't expect array refs to be unwrapped -->
const items = [ref(1)]
{{ items[0] }} // Ref object <!-- ❌ -->
<!-- Don't expect reactive to unwrap nested refs -->
const state = reactive({ nested: { count: ref(0) } })
{{ state.nested.count }} // Ref object <!-- ❌ -->
<!-- Don't destructure reactive without toRefs -->
const { count } = state // loses reactivity <!-- ❌ -->
<!-- Don't use $ prefix for refs -->
{{ $count }} // not a thing <!-- ❌ -->
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Nested ref shows ref object | Not top-level | Use .value |
| Array ref shows ref object | Not top-level | Use .value |
| Reactive nested ref not unwrapped | Only top level | Use .value |
| Destructured ref not reactive | Plain value copied | Use toRefs |
| Prop ref in script | Props are not unwrapped | Use .value |
| Template ref not focused | Missing .value | Use inputRef.value |
| Computed not updating | Missing .value in getter | Use count.value |
Real-World Examples
1. Top-Level Ref
<script setup>
import { ref } from 'vue'
const count = ref(0)
</script>
<template>
<p>{{ count }}</p>
</template>
2. Ref in Script
<script setup>
import { ref } from 'vue'
const count = ref(0)
function increment() {
count.value++
}
</script>
3. Nested Ref
<script setup>
import { ref } from 'vue'
const state = { count: ref(0) }
</script>
<template>
<p>{{ state.count.value }}</p>
</template>
4. Reactive Unwrap
<script setup>
import { ref, reactive } from 'vue'
const count = ref(0)
const state = reactive({ count })
</script>
<template>
<p>{{ state.count }}</p>
</template>
5. Computed
<script setup>
import { ref, computed } from 'vue'
const count = ref(0)
const doubled = computed(() => count.value * 2)
</script>
<template>
<p>{{ doubled }}</p>
</template>
6. Watch
<script setup>
import { ref, watch } from 'vue'
const count = ref(0)
watch(count, (newVal) => {
console.log(newVal)
})
</script>
7. Prop Ref
<script setup>
const props = defineProps(['count'])
console.log(props.count.value)
</script>
8. toRefs
<script setup>
import { reactive, toRefs } from 'vue'
const state = reactive({ count: 0 })
const { count } = toRefs(state)
</script>
<template>
<p>{{ count }}</p>
</template>
9. Template Ref
<script setup>
import { ref, onMounted } from 'vue'
const inputRef = ref(null)
onMounted(() => inputRef.value.focus())
</script>
<template>
<input ref="inputRef">
</template>
10. Array of Refs
<script setup>
import { ref } from 'vue'
const items = [ref(1), ref(2), ref(3)]
</script>
<template>
<p>{{ items[0].value }}</p>
</template>
Visual
Unwrapping Contexts
┌─────────────────────────────────────────────────────────────┐
│ TEMPLATE │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ {{ count }} ← top-level ref: unwrapped │ │
│ │ {{ state.count }} ← nested ref: NOT unwrapped │ │
│ │ {{ items[0] }} ← array ref: NOT unwrapped │ │
│ │ {{ state.count }} ← reactive top-level: unwrapped│ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ SCRIPT │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ count.value ← always .value │ │
│ │ state.count ← reactive: no .value │ │
│ │ state.nested.count.value ← nested: .value │ │
│ │ props.count.value ← prop ref: .value │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
Top-Level vs Nested
┌─────────────────────────────────────────────────────────────┐
│ TOP-LEVEL REF │
│ │
│ const count = ref(0) │
│ │
│ Template: {{ count }} → 0 │
│ Script: count.value → 0 │
│ │
├─────────────────────────────────────────────────────────────┤
│ │
│ NESTED REF │
│ │
│ const state = { count: ref(0) } │
│ │
│ Template: {{ state.count }} → Ref object │
│ Template: {{ state.count.value }} → 0 │
│ Script: state.count.value → 0 │
│ │
└─────────────────────────────────────────────────────────────┘
reactive() Unwrapping
┌─────────────────────────────────────────────────────────────┐
│ const count = ref(0) │
│ const state = reactive({ count }) │
│ │
│ state.count → 0 (unwrapped) │
│ state.count = 10 → updates count.value │
│ count.value → 10 │
│ │
│ The reactive proxy unwraps the ref at the top level. │
│ │
├─────────────────────────────────────────────────────────────┤
│ │
│ const state = reactive({ │
│ nested: { count: ref(0) } │
│ }) │
│ │
│ state.nested.count → Ref object (NOT unwrapped) │
│ state.nested.count.value → 0 │
│ │
│ Only the top level is unwrapped. │
│ │
└─────────────────────────────────────────────────────────────┘
Decision Flow
┌─────────────────────────────────────────────────────────────┐
│ Where is the ref? │
│ │ │
│ ├── Template, top-level binding │
│ │ └── Unwrapped. No .value. │
│ │ │
│ ├── Template, nested (object/array/Map) │
│ │ └── Not unwrapped. Use .value. │
│ │ │
│ ├── Script │
│ │ └── Not unwrapped. Use .value. │
│ │ │
│ ├── reactive() top-level property │
│ │ └── Unwrapped. No .value. │
│ │ │
│ ├── reactive() nested property │
│ │ └── Not unwrapped. Use .value. │
│ │ │
│ └── watch callback │
│ └── Unwrapped. The callback receives the value. │
│ │
└─────────────────────────────────────────────────────────────┘
Summary
| Context | Access |
|---|---|
| Template, top-level ref | count |
| Template, nested ref | state.count.value |
| Template, array ref | items[0].value |
| Script, ref | count.value |
reactive() top-level ref | state.count |
reactive() nested ref | state.nested.count.value |
toRefs() ref, template | count |
toRefs() ref, script | count.value |
| Prop ref, template | props.count |
| Prop ref, script | props.count.value |
| Watch callback | newValue |
| Computed getter | count.value |
| Template ref | inputRef.value |
Key takeaways:
- The template auto-unwraps top-level refs.
{{ count }}readscount.value.@click="count++"writescount.value++. The unwrapping is done by the template compiler. - The script does not unwrap refs.
countis the ref object.count.valueis the value. The.valueis required for reads and writes. - Nested refs are not unwrapped in the template. A ref inside a plain object, an array, or a
Mapis not a top-level binding. The.valueis required. reactive()unwraps refs at the top level. A ref assigned to a top-level property of a reactive object is unwrapped. The property holds the value, and the ref is linked to the property. A ref nested inside an object inside the reactive object is not unwrapped.toRefs()creates refs from a reactive object. The refs are linked to the object’s properties. In the template, they are auto-unwrapped because they are top-level bindings. In the script,.valueis required.watchcallbacks receive the value, not the ref. The ref is unwrapped before the callback is called. Thecomputedgetter, by contrast, must use.valueto access other refs.- Template refs require
.valuein the script. Therefattribute binds the DOM element or component instance to the ref. TheinputRef.valueis the element.
Remember: The unwrapping rules are not arbitrary. They follow from what the template compiler can know. A top-level binding in <script setup> is either a ref or a non-ref, and the compiler can check. A nested property is not known at compile time, so the template does not unwrap it. The script is plain JavaScript, so the .value is always required. The reactive() proxy unwraps refs at the top level as a deliberate convenience. Understand the context, and the rule is clear: top-level in the template, unwrapped; nested anywhere, not; script always, .value.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!