Reactivity in depth
Choose how much Vue tracks with shallowRef(), markRaw() and toRaw(), and split or protect reactive state with toRefs() and readonly().
What you'll learn
When you finish, you can do these.- 1Track a big list cheaply with shallowRef()
- 2Keep a chart library's object working with markRaw()
- 3Split a reactive object into live refs with toRefs()
- 4Share state others can read but not change with readonly()
Picture a sales dashboard: a page that fetches 10,000 data points every minute and draws them with a chart object from a chart library. Vue's defaults cause three problems here. Vue tracks every point for changes, which buys nothing when the list is only ever replaced whole. It wraps the chart object in a stand-in, which can break it. And the dashboard's settings panel cannot pull its theme out into a variable of its own that keeps up with changes.
The Code tab holds a small version of that dashboard in App.vue: three data points, a pretend chart library in chart.ts and a settings object, with buttons to try each. By the end you can fix all three problems. You need two tools from Reactivity basics: ref(), a box that holds a value and tells Vue when it changes, and reactive(), which returns a proxy, a stand-in for an object that reports each read and write to Vue.
shallowRef() tracks the box, not what is inside
The dashboard's list only ever changes whole, and a shallowRef() makes Vue track just that. A plain ref is deep: give it a list of objects and Vue wraps each one in a proxy as you read it, so a change anywhere inside is tracked. For 10,000 points nobody edits, that work buys nothing.
A shallowRef() tracks only .value itself, the box. Assign a new array and the page updates. Change something inside the array and Vue does not notice.
In App.vue, points is a shallowRef holding three numbers, and three buttons change it in three ways: Push only, Push and trigger, and Replace array.
const points = shallowRef([1, 2, 3])
function pushOnly() {
points.value.push(points.value.length + 1) // not tracked
}
function pushAndTrigger() {
points.value.push(points.value.length + 1)
triggerRef(points) // tell Vue by hand
}
function replaceArray() {
points.value = [...points.value, points.value.length + 1] // tracked
}
triggerRef(points) tells Vue by hand that something inside changed. Replacing the array needs no help and is usually clearer; the sales dashboard does it each minute anyway.
Click Push only three times in the preview: the page still says 3 points. Then click Toggle theme, which switches the theme and never touches points: the count jumps to 6. The array was fine all along; the page was out of date. Changing the theme re-rendered the component: a re-render reads every value in the template again, so it read the longer array. A bug like this hides until an unrelated click exposes it.
Proxies, toRaw() and markRaw()
The dashboard's chart object needs no tracking at all, and a proxy can even break it. reactive(obj) does not change obj: it returns a proxy, a separate object that stands in front of yours.
So reactive(obj) === obj is false, and toRaw(proxy) hands back the original, for code that checks whether two objects are the same one, or to read without tracking.
Some library objects keep private fields: properties whose names start with #, which JavaScript lets only the real object read. A proxy is not the real object. The starter's chart.ts plays a chart library, and its Chart class keeps a private #drawCount:
export class Chart {
#drawCount = 0
draw(points: number[]) {
this.#drawCount++
return this.#drawCount
}
}
draw() adds one to #drawCount and returns it. Run on a proxy of the chart, its first line would throw.
markRaw(obj) flags an object so Vue never wraps it, even inside reactive state: use it for chart, map and editor objects. In App.vue, the chart sits in a reactive dashboard object, marked raw:
const dashboard = reactive({ chart: markRaw(new Chart()) })
Click Redraw chart, which calls dashboard.chart.draw(): Vue hands back the real chart, and the count on the page goes up by one.
Split and protect
The settings panel keeps a theme and a font size in one reactive() object, settings, and wants theme as a variable of its own, still linked to it. Destructuring, pulling a property out of a reactive() object into a variable, copies its value, and the copy never updates.
toRefs(settings) instead turns each property into a ref linked to the original, so the destructured theme stays live both ways:
const settings = reactive({ theme: 'light', fontSize: 16 })
const { theme } = toRefs(settings)
Writing theme.value changes settings.theme: click Toggle theme, and the settings printed on the page say "theme": "dark" too. toRef(settings, 'fontSize') does one property, which suits passing a single field to a slider.
readonly(settings) returns a view that refuses writes. The owner, the code that made settings, keeps the writable original; anyone holding the view can read it, and a write through it logs Set operation on key "fontSize" failed: target is readonly. and changes nothing. The Provide and inject lesson, later in this track, uses it to share state one way.
Replacing the whole object is a different problem. If settings were declared with let, settings = reactive({ … }) would make a new proxy without telling anyone, and a watcher (code that runs when a value changes) or a child component still holding the old proxy would keep using it. Keep anything you replace whole in a ref, or copy a saved object's values in with Object.assign(settings, saved).
What goes wrong
Both mistakes put something in a proxy that should stay out. The first is a component kept in ref(). The dashboard has no tabs, so picture an app that does: its App.vue keeps the tab it shows, a component named HomeTab, in a ref named current.
const current = ref(HomeTab)
Vue warns about this one by itself: Vue received a Component that was made a reactive object. This can lead to unnecessary performance overhead and should be avoided by marking the component with `markRaw` or using `shallowRef` instead of `ref`.
const current = shallowRef(HomeTab)
A component is a definition, not state. Vue only needs to know when you swap it, which is what a shallowRef tracks, and the warning goes away.
The second mistake is the starter's chart without markRaw().
const dashboard = reactive({ chart: new Chart() })
Clicking Redraw chart throws TypeError: Cannot read private member #drawCount from an object whose class did not declare it. Reading dashboard.chart returned a proxy of the chart, and draw() ran with that proxy as this, the object a method works on. Mark the instance raw, and Vue hands back the real object.
const dashboard = reactive({ chart: markRaw(new Chart()) })
Each click on Redraw chart adds one to the count again.
What to remember
shallowRef()tracks only assignments to.value. Replace the value, or calltriggerRef(), after changing something inside it.- A stale value that jumps after an unrelated click means a change was not tracked; the re-render merely read the current data.
reactive(obj)returns a proxy that is notobj.toRaw()gets the original back, andmarkRaw()keeps library objects and components out of the proxy system.toRefs()andtoRef()pull properties out of a reactive object without breaking the link.readonly()shares state that only its owner may change.- Use a ref for anything you replace whole. Reassigning a
reactive()variable is not tracked.
Try it
Open the Code tab and change the starter code. Each task says what you should see.- Click Push only three times. The page still says 3 points. Click Toggle theme: it jumps to 6 points, because the re-render read the array as it is now. Click Push and trigger once: 7 points appear at once.
- Add a font-size slider. Import
toRef, addconst fontSize = toRef(settings, 'fontSize'), and in the template<input v-model.number="fontSize" type="range" min="12" max="28" />with<p :style="{ fontSize: fontSize + 'px' }">Preview text</p>. Dragging the slider resizes the text, and the Settings line shows the newfontSize, because the ref writes through tosettings. - Remove
markRaw( … )aroundnew Chart(), keepingnew Chart(), and click Redraw chart. The preview showsCannot read private member #drawCount from an object whose class did not declare it, and the count stays at 0. PutmarkRawback: each click adds one to the count.