In Vue 3, why can holding a large read-only API response in ref() be costly, and when would you use shallowRef() or markRaw() instead?
answer
- deep by default
- a trap on every property read
- reactive at the root only
- replace the root, or triggerRef
basics
~20 sref() makes an object deeply reactive, so every nested read goes through a proxy trap that records a dependency. For large immutable data, shallowRef() or markRaw() skip that work, at the price of updating only by replacement.
solid answer
~40 s`ref()` passes an object value through `reactive()`, which is deep: nested objects are wrapped in proxies the first time they are read, and every property read inside a render or `computed` runs a get trap that records a dependency. With a big array of nested objects, a single render can read 100,000+ properties, which costs time and memory. `shallowRef()` keeps the data raw and tracks only `.value`, so reads are plain property access; the rule becomes 'treat the data as immutable and replace `.value`', or mutate and call `triggerRef()`. `markRaw()` flags one object so it is never proxied, useful for embedding a big dataset or a third-party instance inside otherwise reactive state, but the opt-out applies only to that root object. Reach for either after measuring, not by default.
code
ts · 30 linesimport { shallowRef, triggerRef } from 'vue'
export interface Product {
id: string
name: string
specs: Record<string, string>
}
export function useCatalog() {
// Root-level reactivity only: products are stored raw, never proxied.
const products = shallowRef<Product[]>([])
async function load() {
const res = await fetch('/api/products')
products.value = await res.json()
}
// Replace, don't mutate: a new array with a new object for the changed item.
function rename(id: string, name: string) {
products.value = products.value.map(p => (p.id === id ? { ...p, name } : p))
}
// Or mutate in place and notify once.
function appendMany(batch: Product[]) {
products.value.push(...batch)
triggerRef(products)
}
return { products, load, rename, appendMany }
}go deeper
Remember that ref() of an object is deep and that shallowRef() only reacts when .value is replaced.
Explain where the overhead lives: lazy proxies, a get trap per read, a dependency per tracked property, and the replace-or-triggerRef update rule.
Decide from a profile, not a hunch, and contain the immutability rule inside one composable so nobody mutates nested fields expecting updates.
Treat shallow state as a data-ownership convention: which layers may mutate, which must replace, and how that is documented and reviewed.
## What ref() does with an object In Vue 3, `ref()` holding a primitive tracks reads and writes of `.value`. When the value is an **object or array**, `ref()` converts it with `reactive()`, and `reactive()` is **deep**. The conversion is lazy: the root becomes a proxy immediately, and each nested object becomes a proxy the first time it is read through its reactive parent. The proxy is cached, so later reads return the same proxy. ## Where the overhead comes from For a normal-sized state object this is invisible. For a large dataset - say 20,000 orders, each with line items and nested customer data - the costs add up: - **A get trap on every read.** Each property read inside a render, `computed` or watcher goes through the proxy's `get` handler, which records a dependency for that exact property. - **Dependency bookkeeping.** Every tracked property gets a dependency record linked to the effect that read it; a render that reads 100,000 properties creates that many links to maintain and clean up. - **A proxy per nested object.** Each nested object that is read gets its own proxy, stored in an internal map. - **Identity confusion.** Code that mixes the raw object (from an outside library) and its proxy sees two different identities. The Vue performance guide calls this out specifically: it becomes noticeable with large arrays of deeply nested objects, where a single render touches 100,000+ properties, and it should affect only specific use cases. ## shallowRef(): reactive at the root only `shallowRef()` stores its value **as-is**. Only reading and assigning `.value` is reactive; nothing inside is proxied or tracked. Reads during render are plain JavaScript property accesses. The consequence is a new rule for updates: 1. **Replace** the root: `rows.value = [...rows.value, item]`, or map to a new array with a new object for the changed item. One assignment, one notification. 2. **Mutate, then trigger**: `rows.value.push(item)` followed by `triggerRef(rows)`. Useful when copying a huge array on every change would itself be the cost. 3. **Never** expect `rows.value[0].name = 'x'` to update the view by itself - nested writes are invisible to Vue. ## markRaw() and frozen objects `markRaw(obj)` sets an internal skip flag on `obj`, so `reactive()` returns that object unproxied wherever it appears, even nested inside a reactive parent. Two cautions: - The opt-out is **root-level only**. Objects nested inside a marked object are not marked; if one of them is placed into reactive state through another path, it gets proxied, and the raw and proxied copies are different identities. - The marked object is **not reactive at all**: changing it never updates anything. A related fact: `reactive()` also returns non-extensible objects unchanged, so an `Object.freeze()`d array is not proxied either. Freezing is shallow, but a frozen root read through a normal `ref()` hands back raw items. ## Choosing between them | Tool | What is reactive | Typical big-data use | |---|---|---| | `ref(obj)` / `reactive(obj)` | every nested property | small or frequently edited state | | `shallowRef(obj)` | `.value` only | a dataset replaced wholesale, like an API response | | `shallowReactive(obj)` | root-level properties only | a state object whose top-level fields change | | `markRaw(obj)` | nothing in `obj` | big data or class instances embedded in reactive state | ## When it is worth doing - **Measure first.** The profiler should show time in reactivity (proxy traps, tracking) during render or in `computed` over the dataset. - **Prefer it for data you never edit field by field**: reports, search results, map features, log lines. - **Keep editable state deep.** A form model with a few dozen fields gains nothing from shallow state and loses convenience. - **Document the rule** in the composable that owns the data, because 'mutate a nested field and nothing happens' is the bug a teammate will hit next. ## How to tell it is the problem In a browser performance profile of a slow render over deep data, the time shows up inside Vue's reactivity internals - the proxy `get` handler and dependency tracking - spread across thousands of small calls, rather than inside your own template code or the DOM work. If most of the time is in layout, paint or mounting components instead, shallow state will not help and the fix is rendering fewer rows.
- Is Vue 3's deep conversion done when the data is assigned, like Vue 2's?No. Vue 2 walked the whole object up front to define getters and setters. Vue 3 proxies the root on assignment and wraps nested objects lazily, on first read through the reactive parent. So the cost lands during renders, computed values and watchers that read the data, which is why a component reading every field of a big dataset pays it.
- Why might you choose push plus triggerRef() over replacing the array?Replacing a 50,000-item array with a spread copies every element on each change, which is itself linear work. Mutating the raw array in place and calling `triggerRef()` once notifies dependents without the copy. The cost is discipline: anything that relied on the old array identity sees the same array mutated.
A deep ref is like a building where every drawer has a sensor logging each time it opens; shallowRef keeps one sensor on the front door. You only learn about changes when the whole contents are swapped at the door.
saying these in an interview costs you the question
- Vue 3 walks and converts the whole object the moment it is assigned to ref().
- shallowRef() re-renders the view when a nested field is changed.
- markRaw() makes the object and everything nested inside it permanently non-reactive.
- Every piece of state should use shallowRef() for speed.
- shallowRef() makes its value readonly, so writes throw.