shallowRef and shallowReactive
Opt out of deep tracking for large or foreign objects, and trigger an update by hand with triggerRef.
shallowRef(value) | shallowReactive(target) | triggerRef(shallowRef) | isShallow(value)ref() and reactive() are deep: when you read a nested object out of them, Vue wraps that object in a proxy too, so a change anywhere inside is tracked. That is convenient and it costs memory and time on every access. The shallow variants turn it off.
What stays reactive
shallowRef(value) tracks .value only. Replacing the whole value triggers; mutating something inside it does not. shallowReactive(target) tracks the root-level properties only; nested objects are handed back exactly as they are, unproxied. Note that a shallowReactive object does not unwrap refs stored in its properties, so you read state.total.value there where a deep reactive would have let you write state.total.
Updating by hand
When you do mutate inside a shallowRef, call triggerRef(theRef) to tell Vue the value changed. isShallow(value) reports whether an object came from one of these functions.
Two cases justify the trade. A big collection you always replace wholesale, where proxying thousands of rows buys nothing. And an object you must not proxy at all: a canvas context, a map instance, a WebSocket. For the second case the value is usually better marked with markRaw as well, which is covered in the lesson on raw values.
Example
<script setup lang="ts">
import { shallowRef, triggerRef, computed } from 'vue'
interface Row {
id: number
label: string
}
// 10,000 rows. A deep ref would proxy every row object as it is read.
const rows = shallowRef<Row[]>(
Array.from({ length: 10000 }, (_, i) => ({ id: i, label: `Row ${i}` }))
)
const visible = computed<Row[]>(() => rows.value.slice(0, 5))
function sortDescending(): void {
// A new array means .value itself changed, so the ref triggers on its own.
rows.value = [...rows.value].sort((a, b) => b.id - a.id)
}
function renameFirst(): void {
rows.value[0].label = 'Renamed'
// The write happened inside the value, where nothing is tracked.
triggerRef(rows)
}
</script>
<template>
<p>{{ rows.length }} rows</p>
<ul>
<li v-for="row in visible" :key="row.id">{{ row.label }}</li>
</ul>
<button @click="sortDescending">Sort descending</button>
<button @click="renameFirst">Rename first</button>
</template>
<style scoped>
button {
margin-right: 0.5rem;
}
</style>When to use it
- Holding a multi-thousand row table or a parsed file that is always replaced whole
- Storing a chart, map or editor instance that must not be wrapped in a proxy
- Cutting proxy overhead in a component that re-reads a large object on every frame
- Flagging to the next reader that nested writes are not meant to update the view
More examples
Root level only, and refs are not unwrapped
The two differences from reactive() are easy to miss in review: nested objects are untracked, and ref properties keep their .value. Reach for shallowReactive when the object is a flat bag of primitives that happens to hold one large nested payload you never mutate in place.
// src/demo/shallow-reactive.ts
import { shallowReactive, isReactive, isShallow, ref } from 'vue'
const state = shallowReactive({
count: 0,
nested: { count: 0 },
total: ref(5)
})
console.log(isShallow(state))
// Output: true
// Root-level properties are tracked.
state.count = 1
// Nested objects are plain. Writing state.nested.count triggers nothing.
console.log(isReactive(state.nested))
// Output: false
// A ref property is not unwrapped here, unlike in a deep reactive object.
console.log(state.total.value)
// Output: 5Holding a third-party instance
Anything you did not write yourself goes in a shallowRef: observers, chart instances, map instances, audio nodes, editor instances. You still want the ref so the template can tell whether it exists yet, and you do not want its internals traversed.
// src/composables/useResizeObserver.ts
import { shallowRef, onScopeDispose, type Ref } from 'vue'
export function useResizeObserver(onResize: ResizeObserverCallback) {
// The observer is a browser object with internal state. Proxying it would
// be pointless work, and the proxy identity can confuse foreign code.
const observer: Ref<ResizeObserver | null> = shallowRef(null)
function observe(el: Element): void {
observer.value ??= new ResizeObserver(onResize)
observer.value.observe(el)
}
onScopeDispose(() => {
observer.value?.disconnect()
observer.value = null
})
return { observer, observe }
}Replace, do not mutate
A deliberate update style is what makes a shallow ref safe to work with: treat its value as immutable and assign a new object, keeping triggerRef for the rare in-place write. flush: 'sync' is used here only so the logs appear in source order; the lesson on watchers covers flush timing.
// src/demo/shallow-updates.ts
import { shallowRef, watchEffect, triggerRef } from 'vue'
const doc = shallowRef({ title: 'Draft', words: 0 })
watchEffect(() => {
console.log('title:', doc.value.title)
}, { flush: 'sync' })
// Output: title: Draft
// Mutating inside the value: the data changes, the effect does not run.
doc.value.title = 'Edited in place'
triggerRef(doc)
// Output: title: Edited in place
// Replacing the value: the ref triggers by itself.
doc.value = { title: 'Replaced', words: 1 }
// Output: title: Replaced
Discussion