Vue's reactivity is not free. It's cheap — Proxy lookups are fast, the dependency tracking is incremental, and most apps will never notice — but every property access on a reactive object goes through a Proxy handler, every nested object becomes its own Proxy on first access, and every read inside an active effect is recorded as a dependency.

In day-to-day component code, you don't need to think about any of this. The performance ceiling is high enough that the framework gets out of your way. The cases where you do need to think about it are specific: large frozen datasets, third-party class instances, and rendering a few thousand of anything. For those, Vue gives you escape hatches — shallowRef, shallowReactive, markRaw, triggerRef — that opt out of the parts you don't need.

What Reactivity Actually Costs

Three things, roughly:

  • Proxy creation. When you wrap an object with reactive(), Vue creates a Proxy. When you access a nested object property, Vue lazily wraps that nested object in its own Proxy too. Deeply nested data → many Proxies.
  • Dependency tracking. Every property read inside a reactive effect (computed, watch, watchEffect, render function) registers a dependency. The dep graph isn't free; it's just small.
  • Trigger and patch. When a tracked property changes, every dependent effect is queued. For watch and computed this is a function call. For component renders, this is a re-render and a virtual DOM diff.

For a normal-sized app, all of this is in the microseconds. For a 10,000-row table where every cell is reactive, or a chart library you wrapped in reactive(), it adds up.

shallowRef: The Most Useful Escape Hatch

shallowRef creates a ref whose .value is reactive at the top level only. The contents aren't deeply tracked.

TypeScript
import { shallowRef, triggerRef } from 'vue'

const data = shallowRef<LargeDataset | null>(null)

// replacing the value: reactive
data.value = await fetchHugeDataset()

// mutating the contents: NOT reactive
data.value!.rows.push(newRow)
triggerRef(data) // manually notify subscribers

This is the right tool for any data that's effectively immutable from Vue's perspective — you fetch it, you replace it, you don't mutate its insides. JSON responses, parsed CSVs, anything you'd otherwise spread into a fresh object every change.

The cost saved isn't theoretical. For a 5,000-row dataset with nested objects, the difference between ref() (deep) and shallowRef() is the difference between thousands of Proxy wrappers on first access and zero.

markRaw: For Things That Should Never Be Reactive

Some objects break when you make them reactive. Class instances with private state. Three.js scenes. Chart.js instances. Map and Set in some library wrappers. The Proxy interferes with instanceof checks, with this binding inside methods, or just walks objects that hate being walked.

TypeScript
import { markRaw, ref } from 'vue'
import Chart from 'chart.js/auto'

const chartEl = ref<HTMLCanvasElement | null>(null)
const chart = ref<Chart | null>(null)

onMounted(() => {
  if (!chartEl.value) return
  // markRaw prevents Vue from proxying the Chart instance
  chart.value = markRaw(new Chart(chartEl.value, config))
})

The ref is still reactive (the reference to the chart can change), but the Chart instance itself is excluded from the reactivity system. You can call chart.value.update() without the Proxy interfering.

The same applies to component definitions used in dynamic rendering — a common case in modal/dialog systems and dynamic form builders:

TypeScript
const dialogs = ref<Array<{ id: number; component: Component }>>([])
function open(component: Component) {
  dialogs.value.push({ id: nextId++, component: markRaw(component) })
}

Without markRaw, Vue tries to walk the component definition object, throws warnings, and the runtime cost is real.

shallowReactive For Top-Level State Trees

shallowReactive is to reactive what shallowRef is to ref — only the top level is tracked. Useful for state shapes where you want to replace nested objects rather than mutate them:

TypeScript
import { shallowReactive } from 'vue'

const filters = shallowReactive({
  range: { start: '2025-01-01', end: '2025-12-31' },
  tags: ['vue', 'performance'],
  pagination: { page: 1, perPage: 50 },
})

// reactive — nested object replaced
filters.range = { start: '2025-02-01', end: '2025-12-31' }

// NOT reactive — mutating the nested object
filters.range.start = '2025-02-01'

This pairs well with immutable update patterns. If you're already replacing filters.range instead of mutating it, the deep tracking was never paying you back.

A bonsai-pruning diagram contrasting deep reactivity (every leaf observed) with shallow reactivity (top branches observed, leaves left alone). Annotations show ref vs shallowRef, reactive vs shallowReactive, and markRaw as a &quot;do not enter&quot; sign.
Reactivity is a tool. Like any tool, the right cut matters.

v-once And v-memo For Render Cost

Reactivity is the tracking cost. The other half is the rendering cost — the work Vue does to produce a new VDOM tree on each change.

v-once renders an element exactly once and then never updates it:

Vue
<header v-once>
  <h1>{{ siteTitle }}</h1>
  <Logo />
</header>

If you know a section never changes, this skips the diff entirely. Useful for static page chrome.

v-memo is the more interesting one — it re-renders only when its dependency array changes:

Vue
<div v-for="row in rows" :key="row.id" v-memo="[row.id, row.updatedAt]">
  <ExpensiveCell :row="row" />
</div>

If row.id and row.updatedAt haven't changed, the row is treated as cached. Cheaper than the default per-row diff for big lists where most rows don't change between renders.

Don't reach for these by default. Vue's diff is fast, and v-memo adds complexity. Reach for them when a profiler tells you the render is the bottleneck.

Computed Stability And Reference Identity

A computed that returns a new array or object every time will cascade re-renders to anything depending on it, even if the contents are equivalent.

TypeScript
// Bad: new array reference on every read, even when items are identical
const visibleItems = computed(() =>
  items.value.map((i) => ({ ...i, label: i.name }))
)

If items.value hasn't changed, this still produces a new array on each invocation. Components downstream that take visibleItems as a prop will see a "new" reference and may re-render.

In practice, computed is cached, so the value is only recomputed when its dependencies change. The trap isn't the cache — it's that any change anywhere in the source array invalidates the cache and produces a fresh reference, even if the consuming component only cared about a subset. Solve it by computing closer to where the data is consumed, or by memoizing on a stable key.

Where The Default Is Right

A practical reminder. Most components — forms, lists under a few hundred items, normal app state — are fine with ref and reactive. Don't preemptively reach for shallowRef. The overhead of deep tracking is real but small, and the optimization is only worth it when you have evidence the default is hurting you.

The question to ask: "Does Vue need to know when this nested property changes?" If yes, use the deep version. If no — if you only ever replace the whole thing, or you don't render off the deep contents — use the shallow version.

A One-Sentence Mental Model

Reactivity is observation. Observation is cheap, but observing things you don't need to observe is the only place Vue's defaults can hurt you — so when you handle large frozen data, third-party instances, or anything that gets replaced wholesale, reach for shallowRef, markRaw, or shallowReactive and let Vue look at less.