Vue 3: Understanding the Difference Between `ref` and `shallowRef`
Learn when to use `ref` vs `shallowRef` in Vue 3 and how their reactivity and performance impact your application.

Vue 3: ref
vs shallowRef
– What’s the Difference?
When we implement an application—or even just a feature—we need to consider the performance implications of our reactive
state management choices. In Vue 3’s Composition API, we often reach for ref()
to create reactive variables. However,
Vue also provides shallowRef()
, which appears similar at first glance but behaves quite differently under the hood,
especially in terms of reactivity and performance. In this article, we’ll explore the differences between the two,
benchmark them with large data sets, and identify when each is most appropriate.
✅ What They Have in Common
Both ref
and shallowRef
:
- Are part of Vue’s Composition API
- Wrap any value (primitive or object) and make it reactive
- Return an object with a
.value
property - Can be used in templates and watched with
watch()
orwatchEffect()
Example:
const count = ref(0)
const shallowCount = shallowRef(0)
console.log(count.value) // 0
console.log(shallowCount.value) // 0
🔍 Where They Differ – Deep vs Shallow Reactivity
(Deep) ref()
- The ref() helper provides Deep Reactivity.
- It recursively makes all nested properties reactive.
- It is useful when you need fine-grained reactivity inside objects or arrays.
const user = ref({
name: 'Adam Biro',
preferences: {
theme: 'dark'
}
})
user.value.preferences.theme = 'light' // ✅ reactive update
shallowRef() – Shallow Reactivity
- The shallowRef() helper only makes the top-level
.value
reactive. - Nested properties are not made reactive, and their changes won’t trigger DOM updates or watchers.
const user = shallowRef({
name: 'Adam',
preferences: {
theme: 'dark'
}
})
user.value.preferences.theme = 'light' // ❌ no reactivity triggered
user.value = { ...user.value } // ✅ reactivity triggered (new object)
This behavior above is intentional—and useful when you don’t want Vue to deeply track large or third-party objects.
To clarify what top-level .value
means:
- The entire object ({ person: {...}, preferences: {...} }) is the top-level
.value
. - shallowRef() makes this outer object reactive, but:
- it does not touch or proxy the nested person or preferences objects
- Vue won’t track internal changes like .person.name or .preferences.theme
const user = shallowRef({
person: { name: 'Adam' },
preference: { theme: 'dark' }
})
🚀 Performance Implications
In real-world projects, performance differences can be significant depending on your use case.
1. Faster Initialization
Using ref() on a deeply nested structure or large array causes Vue to walk the object recursively and create proxies for every nested item.
const big = ref({
items: new Array(10000).fill({ active: true })
});
With shallowRef(), Vue only wraps the top-level object:
const big = shallowRef({
items: new Array(10000).fill({ active: true })
});
✅ Result: Faster setup and lower memory usage.
2. Avoiding Unnecessary Renders
- Changes deep inside a ref-wrapped object can trigger re-renders or watchers, even if you're not displaying those properties in the DOM.
- With shallowRef(), such internal changes don’t trigger updates:
// Using ref
user.value.preferences.fontSize++ // will re-render
// Using shallowRef
user.value.preferences.fontSize++ // won't re-render
✅ Result: Better control over what actually triggers change detection.
3. Safer for Third-party Instances
When working with libraries like Leaflet, Chart.js, or any class instance, you usually don’t want Vue to proxy every method and property.
const chart = shallowRef(null)
onMounted(() => {
chart.value = new Chart(ctx, options)
})
✅ Result: Avoid reactivity issues and proxy-related bugs.
💡 When to Use Which?
Situation | Use ref() | Use shallowRef() |
---|---|---|
Primitive value | ✅ | ✅ |
Nested object with reactive properties | ✅ | ❌ |
Third-party library objects (Map, Chart) | ❌ | ✅ |
Performance-sensitive large objects | ❌ | ✅ |
Want deep reactivity | ✅ | ❌ |
Only care about top-level .value changes | ❌ | ✅ |
🔬 Vue 3 Performance Benchmark: ref()
vs shallowRef()
This benchmark code below compares the performance characteristics and reactivity behavior of ref()
and shallowRef()
in Vue 3 when dealing with large, nested data structures.
Test Summary
- A data structure with 100,000 deeply nested items is generated via
generateLargeData()
. - The
triggerDeepAccess()
function accesses every nested property to force Vue's lazy deep reactivity mechanism to proxy the entire object graph. - The
ref()
andshallowRef()
wrappers are applied separately, and initialization time is measured withperformance.now()
. - A
watch()
is set up on a specific deeply nested property (items[50000].details.price
) in both cases. - After a delay, the watched property is mutated to observe whether reactivity is triggered.
Expected Behavior
ref()
will recursively convert nested structures into reactive proxies upon access. Thus, the watcher will be triggered after mutation.shallowRef()
only proxies the top-level value; nested changes are not tracked. Therefore, the watcher will not be triggered.shallowRef()
shows significantly lower initialization cost and avoids the overhead of recursive proxy generation.
Use Case
This benchmark highlights the importance of choosing shallowRef()
when:
- You only need to react to top-level changes
- Working with third-party class instances or static nested data
- Optimizing performance for large datasets that don’t require full reactivity
For scenarios requiring deep reactivity (e.g., DOM-bound nested structures), ref()
remains the appropriate choice.
<script setup lang="ts">
import { ref, shallowRef, watch, onMounted } from 'vue'
function triggerDeepAccess(obj) {
for (let i = 0; i < obj.items.length; i++) {
const item = obj.items[i]
void item.name
void item.details.price
void item.details.active
}
}
function generateLargeData() {
return {
items: Array.from({ length: 100_000 }, (_, i) => ({
name: `Item ${i}`,
details: {
price: i,
active: true
}
}))
}
}
onMounted(() => {
const TARGET_INDEX = 50000
// Deep ref setup
const data1 = generateLargeData()
const t1 = performance.now()
const deep = ref(data1)
triggerDeepAccess(deep.value)
const t2 = performance.now()
const deepTime = (t2 - t1).toFixed(3)
const deepCount = deep.value.items.length
// Watch a deep property
watch(
() => deep.value.items[TARGET_INDEX].details.price,
(newVal, oldVal) => {
console.log(`🔁 deep watcher triggered: ${oldVal} → ${newVal}`)
}
)
// Mutate it after a tick
setTimeout(() => {
deep.value.items[TARGET_INDEX].details.price++
}, 500)
// Shallow ref setup
const data2 = generateLargeData()
const t3 = performance.now()
const shallow = shallowRef(data2)
triggerDeepAccess(shallow.value)
const t4 = performance.now()
const shallowTime = (t4 - t3).toFixed(3)
const shallowCount = shallow.value.items.length
// Watch the same nested property in shallowRef
watch(
() => shallow.value.items[TARGET_INDEX].details.price,
(newVal, oldVal) => {
console.log(`🔁 shallow watcher triggered: ${oldVal} → ${newVal}`)
}
)
setTimeout(() => {
shallow.value.items[TARGET_INDEX].details.price++
}, 500)
console.log(`⏱️ Deep ref init time: ${deepTime} ms (${deepCount} items)`)
console.log(`⏱️ Shallow ref init time: ${shallowTime} ms (${shallowCount} items)`)
})
🖥️ Console Output Snapshot
The following screenshot captures the actual console output from the benchmark test. As expected, the initialization
time for ref()
is significantly higher due to Vue’s deep proxying, while shallowRef()
remains much faster.
Additionally, only the ref()
triggers the watcher upon mutation of a deeply nested property, confirming its deep
reactivity behavior:
