In Vue 3, what does `shallowRef()` track, and why does `list.value.push(item)` on one not update the view without `triggerRef()`?
answer
- only the .value access is reactive
- inner value stored as-is
- replace, or force a trigger
- same object fails the change check
basics
~20 sshallowRef tracks only reads and writes of .value itself; the inner value is stored as-is, not proxied, so mutating inside it runs no trap and triggers nothing. Assign a new value to .value, or call triggerRef(ref) after the mutation, to notify dependents.
solid answer
~40 sA normal `ref()` passes an object value through `reactive()`, so nested writes are tracked. A `shallowRef()` skips that: `.value` holds the raw object, and only the `.value` getter and setter call `track()` and `trigger()`. So `list.value.push(item)` mutates a plain array that no effect is subscribed to. There are two ways to publish the change: assign a new value (`list.value = [...list.value, item]`), which the setter sees as changed, or mutate in place and then call `triggerRef(list)`, which forces the ref's dep to notify its subscribers. Reassigning the same object (`list.value = list.value`) does nothing, because the setter's `Object.is` check sees no change. `shallowReactive()` applies the same idea to an object: only root-level properties are reactive.
code
vue · 22 lines<script setup lang="ts">
import { shallowRef, triggerRef } from 'vue'
const rows = shallowRef<{ id: number; label: string }[]>([])
function appendImmutable(label: string) {
rows.value = [...rows.value, { id: rows.value.length, label }]
}
function appendInPlace(label: string) {
rows.value.push({ id: rows.value.length, label })
triggerRef(rows) // without this line the list would not re-render
}
</script>
<template>
<button @click="appendImmutable('a')">replace</button>
<button @click="appendInPlace('b')">mutate + triggerRef</button>
<ul>
<li v-for="row in rows" :key="row.id">{{ row.label }}</li>
</ul>
</template>go deeper
Recall that shallowRef only reacts when .value itself is replaced, and that triggerRef forces an update after a nested change.
Explain why no trap runs for nested writes, why reassigning the same object fails the Object.is check, and how shallowReactive differs.
Choose shallow refs deliberately for large or externally owned data and enforce a consistent replace-or-triggerRef rule in reviews.
Set the team's default for big or external state - deep proxies, shallow refs with immutable updates, or raw objects - and document the update contract.
## What "shallow" means for a ref `ref(value)` stores an object value after converting it with `reactive()`, so `state.value.items.push(x)` goes through a proxy and triggers. `shallowRef(value)` stores the value **as-is**: - the `.value` **getter** calls `track()` - effects that read `.value` subscribe; - the `.value` **setter** calls `trigger()` - but only when the new value differs from the old one by `Object.is`; - the object inside is a plain object with no proxy, so reads and writes of its properties are invisible to Vue. The docs summarise it as: only the `.value` access is reactive. ## Why the push does nothing ```ts const list = shallowRef<string[]>([]) list.value.push('a') // reads .value (tracked), then mutates a plain array ``` The `push` runs on a raw array. No trap runs, `trigger()` is never called, and the component that rendered `list.value` has no reason to re-render. ## Two ways to publish a change 1. **Replace the value.** `list.value = [...list.value, 'a']` assigns a different array; the setter sees a change and triggers. This fits naturally with immutable update style and is what the docs recommend for integrating external or immutable state. 2. **Mutate, then force it.** `list.value.push('a'); triggerRef(list)` - `triggerRef()` calls `trigger()` on the ref's dep unconditionally, so every effect that read `.value` is notified. Useful when copying a large structure on every change would be wasteful. The tempting third way does **not** work: ```ts list.value.push('a') list.value = list.value // same array: Object.is says unchanged, no trigger ``` ## `ref` vs `shallowRef` vs `shallowReactive` | | `ref(obj)` | `shallowRef(obj)` | `shallowReactive(obj)` | |---|---|---|---| | What is reactive | `.value` and every nested property | `.value` only | root-level properties only | | Inner objects | converted to reactive proxies | stored as-is | stored as-is | | Nested refs unwrapped | yes | not applicable | no | | Publish a nested change | just mutate | replace `.value` or `triggerRef()` | replace the root property | `shallowReactive()` has no `triggerRef()` equivalent; you replace the root-level property with a new object. The docs warn that shallow structures belong at the root of a component's state, not nested inside deep reactive objects, where the mix of behaviours becomes hard to follow. ## When to choose a shallow ref - **Large data you replace wholesale** - a fetched result set, a parsed document - where proxying thousands of nested objects buys nothing. - **State owned by something else** - an immutable-data library or an external store whose updates arrive as new objects; hold it in a `shallowRef` and replace `.value`. - **Objects that must not be proxied** - class instances and third-party objects, as long as only swapping the instance should notify. When you do use one, make the update rule explicit in code review: every nested change is either a replacement or is followed by `triggerRef()`. ## Watchers and shallow refs A watcher on a shallow ref fires when `.value` is replaced or when `triggerRef()` notifies it. A nested mutation without `triggerRef()` is as invisible to a watcher as it is to the template, for the same reason: no trap ran. ## Common mistakes - Assuming `shallowRef` is just a faster `ref` with the same behaviour. - Reassigning the same object and expecting an update. - Calling `triggerRef()` on the inner object rather than on the ref. - Mixing both styles on the same shallow ref, so that some code paths replace `.value` and others mutate without `triggerRef()`; the missed updates then look random. ## Explaining it in an interview A compact answer has three sentences. A shallow ref tracks only its `.value` access, so the object inside is plain and nested writes are invisible. To publish a change you either replace `.value` with a new object or call `triggerRef()` after mutating. Reassigning the same object does not count, because the setter compares old and new values with `Object.is`. Then name one reason you would choose it - large data replaced wholesale, or state owned by an external library - and the rule you enforce so nobody forgets the trigger.
- In Vue 3, does `triggerRef()` work on a normal `ref()` too?It will notify the ref's subscribers, but it is rarely needed: a normal ref's object value is a reactive proxy, so nested mutations already trigger. `triggerRef()` exists for shallow refs, whose inner value is not proxied.
- Why do the Vue docs warn against nesting `shallowReactive()` inside deep reactive state?The result mixes two update rules in one tree: some branches track nested writes and others only track replacement. Readers of the code cannot tell which rule applies where, and bugs look like random missed updates. Keep shallow structures at the root of a component's state.
A shallow ref is a labelled envelope on a notice board. Readers notice when the envelope is swapped for a new one, but not when someone edits the letter inside; triggerRef() is pinning a note that says 'the letter changed, reread it'.
saying these in an interview costs you the question
- shallowRef is a faster ref with identical update behaviour.
- Assigning list.value = list.value after a push forces an update.
- triggerRef() must be passed the inner object that was mutated.
- shallowReactive makes nested properties reactive but not the root.
- Pushing into a shallowRef array triggers because push is instrumented.