Vue.js 88 🟢 Non-Tracking Reactivity with shallowRef() and shallowReactive()
Vue’s default reactivity is deep. A ref({ nested: { count: 1 } }) wraps every nested object in a Proxy, so mutating nested.count triggers a re-render. This is convenient, but it is also expensive for large data structures, external library instances, and immutable API responses that will never be mutated internally. shallowRef() and shallowReactive() provide the shallow alternative: only the top-level reference or the root-level properties are reactive, and nested objects are stored as-is without being converted into Proxies.
This chapter covers the two shallow utilities, the manual trigger that compensates for the missing deep tracking, the markRaw exemption for individual objects, and the cases where shallow reactivity is the correct choice rather than a premature optimization.
Key point: shallowRef only tracks .value replacement. Mutating shallow.value.nested does not trigger an update. shallowReactive only tracks root-level property assignments. Mutating shallow.nested.count does not trigger an update. If you must mutate internally and still need an update, triggerRef() forces the notification manually.
Why shallow reactivity exists
The performance problem. Deep reactivity means every nested object, array, and class instance inside a reactive structure gets wrapped in a Proxy. For a 10,000-item array from an API, that is 10,000+ Proxy creations on assignment, plus the memory overhead of tracking every nested property. The data is often read-only from the application’s perspective—it was fetched, displayed, and replaced wholesale on the next fetch. Deep tracking provides no benefit.
The external library problem. A Mapbox map instance, a Monaco editor, or a WebSocket manager has its own internal state management. Wrapping it in a Proxy can break the library’s this references, cause identity mismatches, or trigger Vue warnings. shallowRef and markRaw are the mechanisms for storing these instances without Vue interfering with their internals.
The component reference problem. Storing a Vue component definition in ref() or reactive() triggers the warning “Vue received a Component that was made a reactive object.” The component’s internals should not be proxied. shallowRef or markRaw is the correct storage mechanism for dynamic component references.
a. shallowRef: Only .value Is Reactive
shallowRef() returns a ref whose .value access is tracked, but whose inner value is stored as-is without deep conversion.
import { shallowRef } from 'vue'
const state = shallowRef({ count: 1 })
// Does NOT trigger an update
state.value.count = 2
// DOES trigger an update
state.value = { count: 2 }
The distinction is between mutating the object and replacing the object. The getter returns the raw object, and the setter replaces the .value reference. Only the replacement triggers dependents.
The common use cases are large API responses, external library instances, and immutable data:
// Large API response: replace, do not mutate
const users = shallowRef(await fetchUsers())
users.value = await fetchUsers() // triggers update
// External library instance
const map = shallowRef(null)
onMounted(() => {
map.value = new mapboxgl.Map({ container: 'map' })
})
If you must mutate internally and still notify dependents, triggerRef() forces the update:
import { shallowRef, triggerRef } from 'vue'
const items = shallowRef([1, 2, 3])
items.value.push(4) // no update
triggerRef(items) // forced update
The Vue documentation uses this exact example with watchEffect to demonstrate the manual trigger.
b. shallowReactive: Only Root-Level Properties Are Reactive
shallowReactive() returns a Proxy for the root object only. Nested objects are stored as raw values, not converted into reactive proxies.
import { shallowReactive, isReactive } from 'vue'
const state = shallowReactive({
foo: 1,
nested: { bar: 2 }
})
// Reactive: root-level assignment
state.foo++
// Not reactive: nested object is not a Proxy
isReactive(state.nested) // false
state.nested.bar++ // does NOT trigger update
The Vue documentation warns that shallow data structures should only be used for root-level state in a single component. Nesting a shallow reactive object inside a deep reactive object creates a tree with inconsistent reactivity behavior that is difficult to debug.
If a nested object needs to become reactive later, it can be converted explicitly:
import { shallowReactive, reactive } from 'vue'
const state = shallowReactive({
nested: { count: 0 }
})
// Convert the nested object to reactive when needed
state.nested = reactive(state.nested)
// Now state.nested.count++ is tracked
This explicit conversion is the trade-off: the developer controls when the cost of deep proxying is paid, rather than paying it upfront for the entire structure.
c. Combining with markRaw
markRaw() is a related utility that marks an object to never be converted into a reactive proxy, even when nested inside a deep reactive structure. The difference from shallowRef is scope: shallowRef makes the entire ref shallow; markRaw exempts a specific object from deep conversion while the surrounding structure remains deep.
import { reactive, markRaw } from 'vue'
const state = reactive({
count: 0, // deeply reactive
map: markRaw(new MapboxMap()), // never proxied
})
The Vue documentation notes that markRaw only affects the root level. A nested object inside a markRaw object may still be proxied if accessed through a reactive parent. shallowRef is the safer container for objects that should never be reactive.
Complete Example Session
// ============================================
// PART 1: shallowRef — REPLACE TO TRIGGER
// ============================================
import { shallowRef, watchEffect } from 'vue'
const data = shallowRef({ value: 1 })
watchEffect(() => console.log(data.value.value))
// Logs: 1
data.value.value = 2
// No log; internal mutation is not tracked
data.value = { value: 3 }
// Logs: 3; .value replacement is tracked
// ============================================
// PART 2: shallowReactive — ROOT LEVEL ONLY
// ============================================
import { shallowReactive, watchEffect, isReactive } from 'vue'
const state = shallowReactive({
count: 0,
nested: { deep: 1 }
})
watchEffect(() => console.log(state.count))
// Logs: 0
state.count++
// Logs: 1; root-level property is tracked
state.nested.deep++
// No log; nested is not reactive
console.log(isReactive(state.nested)) // false
// ============================================
// PART 3: triggerRef — MANUAL UPDATE
// ============================================
import { shallowRef, triggerRef, watchEffect } from 'vue'
const list = shallowRef([1, 2])
watchEffect(() => console.log(list.value.length))
// Logs: 2
list.value.push(3)
// No log
triggerRef(list)
// Logs: 3
// ============================================
// PART 4: EXTERNAL LIBRARY INSTANCE
// ============================================
import { shallowRef, markRaw } from 'vue'
const map = shallowRef(null)
map.value = markRaw(new MapboxMap({ container: 'map' }))
// No Proxy wrapping of the map instance
// ============================================
// PART 5: DYNAMIC COMPONENT
// ============================================
import { shallowRef, type Component } from 'vue'
const current = shallowRef<Component>(ComponentA)
current.value = ComponentB
// No warning about reactive component
// ============================================
// PART 6: markRaw INSIDE reactive
// ============================================
import { reactive, markRaw } from 'vue'
const state = reactive({
count: 0,
map: markRaw(new MapboxMap()), // never proxied
})
The six parts trace the shallow ref replacement rule, the shallow reactive root-level rule, the manual trigger, and the two integration patterns for external objects and components.
Quick Reference
shallowRef vs shallowReactive
| Aspect | shallowRef | shallowReactive |
|---|---|---|
| Access | .value | Direct properties |
| Reactive trigger | .value replacement | Root property assignment |
| Internal mutation | Not tracked | Not tracked (nested) |
| Manual trigger | triggerRef(ref) | Not available |
| Use case | Large data, external objects | Root-level state only |
When to Use Each
| Scenario | Use |
|---|---|
| Large API response, replaced wholesale | shallowRef |
| External library instance | shallowRef + markRaw |
| Dynamic component reference | shallowRef |
| Root-level form state | shallowReactive |
| Nested state needs reactivity | reactive or ref |
Related Utilities
| Utility | Purpose |
|---|---|
triggerRef(ref) | Force update on a shallow ref |
markRaw(obj) | Never proxy this object |
isReactive(obj) | Check if a proxy |
Best Practices
✅ Do This:
// Replace .value for large data
users.value = await fetchUsers() // ✅
// Use triggerRef after internal mutation
items.value.push(newItem)
triggerRef(items) // ✅
// Use markRaw for external instances
const map = shallowRef(markRaw(new Map())) // ✅
❌ Don’t Do This:
// Mutate shallowRef internals without triggerRef
shallow.value.count++ // no update // ❌
// Nest shallowReactive inside reactive
reactive({ sub: shallowReactive({}) }) // inconsistent reactivity // ❌
// Use ref for large read-only data
const users = ref(await fetchUsers()) // unnecessary deep proxy // ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| UI does not update after mutation | shallowRef internal mutation not tracked | Replace .value or triggerRef |
| Nested property not reactive | shallowReactive only root level | Convert nested to reactive explicitly |
| Component warning | Component stored in ref/reactive | Use shallowRef or markRaw |
| Library breaks | Instance wrapped in Proxy | Use shallowRef + markRaw |
Real-World Examples
1. Large API Response
const users = shallowRef(await fetchUsers())
users.value = await fetchUsers() // replace to trigger
2. Manual Trigger
items.value.push(newItem)
triggerRef(items)
3. External Library
const map = shallowRef(markRaw(new MapboxMap()))
4. Dynamic Component
const current = shallowRef<Component>(ComponentA)
5. Root-Level Form State
const form = shallowReactive({ name: '', email: '' })
6. Convert Nested Explicitly
state.nested = reactive(state.nested)
7. markRaw Inside Deep
const state = reactive({ map: markRaw(new MapboxMap()) })
8. Check Reactivity
isReactive(state.nested) // false
9. Replace Entire Array
list.value = [...list.value, newItem] // triggers update
10. Shallow for Component Props
const props = shallowReactive(props)
Visual
shallowRef: Replace vs. Mutate
┌──────────────────────────────────────────────────────────────┐
│ const state = shallowRef({ count: 1 }) │
│ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ state.value │ │ state.value = │ │
│ │ .count = 2 │ │ { count: 2 } │ │
│ └────────┬────────┘ └────────┬────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ No update │ │ Update triggered│ │
│ │ (internal) │ │ (.value replaced)│ │
│ └─────────────────┘ └─────────────────┘ │
│ │
│ Only .value assignment is tracked. │
│ │
└──────────────────────────────────────────────────────────────┘
shallowReactive: Root Level Only
┌──────────────────────────────────────────────────────────────┐
│ shallowReactive({ count: 0, nested: { deep: 1 } }) │
│ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ state.count++ │ │ state.nested │ │
│ │ │ │ .deep++ │ │
│ └────────┬────────┘ └────────┬────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ Reactive │ │ Not reactive │ │
│ │ (root property) │ │ (nested) │ │
│ └─────────────────┘ └─────────────────┘ │
│ │
│ isReactive(state.nested) → false │
│ │
└──────────────────────────────────────────────────────────────┘
triggerRef: Manual Override
┌──────────────────────────────────────────────────────────────┐
│ const list = shallowRef([1, 2]) │
│ │
│ list.value.push(3) │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ No update │ │
│ └────────┬────────┘ │
│ │ triggerRef(list) │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Update triggered│ │
│ │ manually │ │
│ └─────────────────┘ │
│ │
│ The manual trigger compensates for the missing deep track. │
│ │
└──────────────────────────────────────────────────────────────┘
External Object Storage
┌──────────────────────────────────────────────────────────────┐
│ ❌ WRONG: deep proxy of a library instance │
│ const map = ref(new MapboxMap()) // breaks internals │
│ │
│ ✅ CORRECT: shallowRef + markRaw │
│ const map = shallowRef(markRaw(new MapboxMap())) │
│ │
│ ┌─────────────────┐ │
│ │ shallowRef │ .value tracked │
│ │ markRaw(instance)│ instance never proxied │
│ └─────────────────┘ │
│ │
│ The library's `this` references remain intact. │
│ │
└──────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
shallowRef | Only .value is reactive |
shallowReactive | Only root-level properties are reactive |
triggerRef | Manual update for shallow ref |
markRaw | Never proxy this object |
| Deep reactivity cost | Proxy per nested object |
| Shallow benefit | No deep proxy overhead |
| Nested conversion | reactive(nested) explicitly |
| Component warning | Use shallowRef for component refs |
Key takeaways:
shallowReftracks only.valuereplacement. The inner object is stored as-is. Mutatingshallow.value.countdoes not trigger an update; assigningshallow.value = { count: 2 }does.triggerRef()is the manual override for cases where internal mutation must notify dependents.shallowReactivetracks only root-level properties. Nested objects are not converted to Proxies.isReactive(state.nested)returnsfalse. The Vue documentation warns against nesting shallow reactive objects inside deep ones because the reactivity becomes inconsistent and hard to debug.markRawis the exemption utility for deep structures. It marks a specific object to never be proxied, while the surrounding structure remains deeply reactive.shallowRefis the container-level alternative when the entire structure should be shallow.- External library instances and dynamic components should not be deeply reactive. Wrapping a Mapbox map, Monaco editor, or Vue component definition in
ref()orreactive()causes warnings, identity issues, and performance overhead.shallowRefandmarkRaware the correct storage mechanisms. - Shallow reactivity trades convenience for control. The developer must replace
.valueor calltriggerRefto trigger updates. In exchange, the deep Proxy traversal is avoided, and the reactivity behavior is explicit rather than automatic.
Remember: shallowRef and shallowReactive are not “broken” versions of ref and reactive. They are the same reactivity system with the deep conversion turned off. The getter and setter still exist, and track and trigger still work—they simply operate on the top-level reference or root properties rather than recursively on every nested object. The choice between deep and shallow is a choice about where the reactivity boundary lies: at the root of the data structure, or at every leaf. For large data that is replaced wholesale, the boundary belongs at the root. For state that is mutated deeply, the boundary belongs at the leaves, and the deep utilities are the correct choice.
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!