| |

Vue.js 88 🟢 Non-Tracking Reactivity with shallowRef() and shallowReactive()

⏱️ Estimated read: 9 min | |

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

AspectshallowRefshallowReactive
Access.valueDirect properties
Reactive trigger.value replacementRoot property assignment
Internal mutationNot trackedNot tracked (nested)
Manual triggertriggerRef(ref)Not available
Use caseLarge data, external objectsRoot-level state only

When to Use Each

ScenarioUse
Large API response, replaced wholesaleshallowRef
External library instanceshallowRef + markRaw
Dynamic component referenceshallowRef
Root-level form stateshallowReactive
Nested state needs reactivityreactive or ref

Related Utilities

UtilityPurpose
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

PitfallWhy It HappensFix
UI does not update after mutationshallowRef internal mutation not trackedReplace .value or triggerRef
Nested property not reactiveshallowReactive only root levelConvert nested to reactive explicitly
Component warningComponent stored in ref/reactiveUse shallowRef or markRaw
Library breaksInstance wrapped in ProxyUse 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

ItemValue
shallowRefOnly .value is reactive
shallowReactiveOnly root-level properties are reactive
triggerRefManual update for shallow ref
markRawNever proxy this object
Deep reactivity costProxy per nested object
Shallow benefitNo deep proxy overhead
Nested conversionreactive(nested) explicitly
Component warningUse shallowRef for component refs

Key takeaways:

  • shallowRef tracks only .value replacement. The inner object is stored as-is. Mutating shallow.value.count does not trigger an update; assigning shallow.value = { count: 2 } does. triggerRef() is the manual override for cases where internal mutation must notify dependents.
  • shallowReactive tracks only root-level properties. Nested objects are not converted to Proxies. isReactive(state.nested) returns false. The Vue documentation warns against nesting shallow reactive objects inside deep ones because the reactivity becomes inconsistent and hard to debug.
  • markRaw is the exemption utility for deep structures. It marks a specific object to never be proxied, while the surrounding structure remains deeply reactive. shallowRef is 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() or reactive() causes warnings, identity issues, and performance overhead. shallowRef and markRaw are the correct storage mechanisms.
  • Shallow reactivity trades convenience for control. The developer must replace .value or call triggerRef to 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.

Was this helpful?


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!