Advanced Reactivity
In Reactivity: ref & reactive you learned that Vue updates the page when reactive data changes. That is enough for small apps. In real projects you will meet bugs where "the page does not update", slow screens with thousands of rows, and composables that leave watchers running after they are no longer needed.
This lesson explains how Vue tracks changes, the common ways reactivity gets lost, and the advanced tools that fix each problem: toRef() and toRefs(), shallowRef() with triggerRef(), markRaw(), customRef() and effectScope().
How Vue tracks changes
Vue wraps objects in a JavaScript Proxy, an object that can watch every property read and write. Whenever an effect runs (a component render, a computed(), a watchEffect()), Vue records which reactive properties it reads. Later, when one of those properties is written, Vue re-runs only the effects that read it.
Two rules follow from this, and almost every reactivity bug breaks one of them:
- Reactivity lives on the proxy or the ref, not on the value. If you copy a value out (
const runs = score.runs), you get a plain number with no link back. - Only reads that happen while the effect runs are tracked. In
watchEffect(async () => { ... }), properties read after the firstawaitare not tracked, because the effect has already finished its synchronous part.
A third, smaller rule: reactive(obj) returns a proxy that is a different object from obj. reactive(obj) === obj is false, so keep using the proxy, not the original.
Where reactivity gets lost, and toRef/toRefs
The classic mistake is destructuring a reactive object or passing one property to a function. Both copy the current value. toRefs() turns every property into a ref that stays linked to the source, and toRef(obj, "key") does it for one property:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Losing reactivity</title>
<script src="https://cdn.jsdelivr.net/npm/vue@3.5.44/dist/vue.global.prod.js"></script>
<style>body { font-family: system-ui, sans-serif; margin: 16px; } td { padding: 4px 10px; }</style>
</head>
<body>
<div id="app">
<p><button @click="score.runs += 4">Four!</button> <button @click="score.wickets++">Wicket</button></p>
<table>
<tr><td>score.runs (proxy)</td><td>{{ score.runs }}</td></tr>
<tr><td>destructured runs</td><td>{{ copiedRuns }}</td></tr>
<tr><td>toRefs runs</td><td>{{ runs }}</td></tr>
<tr><td>toRef wickets</td><td>{{ wickets }}</td></tr>
<tr><td>summary(score.runs) at setup</td><td>{{ frozenSummary }}</td></tr>
<tr><td>summary from getter</td><td>{{ liveSummary }}</td></tr>
</table>
</div>
<script>
const { createApp, reactive, toRefs, toRef, computed } = Vue;
createApp({
setup() {
const score = reactive({ team: "Jaipur XI", runs: 120, wickets: 3 });
const { runs: copiedRuns } = score; // plain number: lost
const { runs } = toRefs(score); // ref linked to score.runs
const wickets = toRef(score, "wickets"); // one linked ref
const summary = (r) => `${r} runs`;
const frozenSummary = summary(score.runs); // computed once: lost
const liveSummary = computed(() => summary(score.runs)); // re-reads every time
return { score, copiedRuns, runs, wickets, frozenSummary, liveSummary };
}
}).mount("#app");
</script>
</body>
</html>Click "Four!" a few times. The rows built from a copied value stay at 120, the others follow the score. The same idea applies to props: toRef(props, "city") or a getter () => props.city keeps a composable connected to a prop. (In Vue 3.5, destructuring defineProps() directly in <script setup> is compiled to stay reactive, but passing the destructured variable into a function still needs a getter.)
shallowRef and triggerRef for big data
ref() is deep: it wraps every nested object in a proxy. For a list of 10,000 rows that you only ever replace as a whole, such as a report downloaded from a server, that work is wasted. shallowRef() only tracks assignments to .value. Nested changes are ignored until you either assign a new value or call triggerRef():
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>shallowRef</title>
<script src="https://cdn.jsdelivr.net/npm/vue@3.5.44/dist/vue.global.prod.js"></script>
<style>body { font-family: system-ui, sans-serif; margin: 16px; }</style>
</head>
<body>
<div id="app">
<p>{{ rows.length }} sales rows. First row: {{ rows[0].shop }}, ₹{{ rows[0].amount }}</p>
<p>
<button @click="mutateOnly">Change first row (no update)</button>
<button @click="forceUpdate">triggerRef()</button>
<button @click="replaceAll">Replace whole list</button>
</p>
</div>
<script>
const { createApp, shallowRef, triggerRef } = Vue;
function makeRows(n, base) {
return Array.from({ length: n }, (_, i) => ({ id: i, shop: "Shop " + (i + 1), amount: base + i }));
}
createApp({
setup() {
const rows = shallowRef(makeRows(10000, 500));
function mutateOnly() { rows.value[0].amount += 100; } // not tracked
function forceUpdate() { triggerRef(rows); } // tell Vue "it changed"
function replaceAll() { rows.value = makeRows(10000, 900); } // tracked
return { rows, mutateOnly, forceUpdate, replaceAll };
}
}).mount("#app");
</script>
</body>
</html>Click the first button twice: nothing changes on screen, although the data did. Click triggerRef() and the screen jumps by ₹200. The safest habit with shallowRef() is to treat the data as immutable: never change it in place, always assign a new array or object. shallowReactive() is the same idea for a reactive() object: only top-level properties are tracked.
markRaw(obj) goes one step further: Vue never wraps that object in a proxy at all. Use it for objects from other libraries, such as a chart instance or a map, which do not expect to be proxied and gain nothing from it. toRaw(proxy) gives you back the original object behind a proxy, for example to pass to such a library; writes through it do not trigger updates.
customRef: build a debounced ref step by step
customRef() lets you decide when a ref tracks and when it triggers. A common use is a debounced search box: the input updates instantly, but the value other code sees only changes after the user stops typing for 400 ms, so you do not send an API request on every key press.
- Create the factory.
customRef((track, trigger) => ({ get, set }))gives you two functions:track()says "an effect depends on me",trigger()says "I changed". - Call
track()inget()and return the stored value, so computed properties and templates depend on the ref. - Delay
trigger()inset(). Store the new value, clear any pending timer and start a new one that callstrigger()when it fires. - Wrap it in a function
useDebouncedRef(initial, delay)so it can be reused like any composable.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>customRef debounce</title>
<script src="https://cdn.jsdelivr.net/npm/vue@3.5.44/dist/vue.global.prod.js"></script>
<style>body { font-family: system-ui, sans-serif; margin: 16px; }</style>
</head>
<body>
<div id="app">
<label>Search stations: <input v-model="typed"></label>
<p>Searches sent: {{ searches }}</p>
<ul><li v-for="s in results" :key="s">{{ s }}</li></ul>
</div>
<script>
const { createApp, ref, customRef, computed, watch } = Vue;
function useDebouncedRef(initial, delay = 400) {
let value = initial;
let timer;
return customRef((track, trigger) => ({
get() {
track();
return value;
},
set(newValue) {
value = newValue;
clearTimeout(timer);
timer = setTimeout(trigger, delay);
}
}));
}
const STATIONS = ["Rohtak Jn", "Jaipur Jn", "Pune Jn", "Kochi (Ernakulam)", "Lucknow Charbagh", "Rewari Jn", "Kota Jn"];
createApp({
setup() {
const typed = ref("");
const query = useDebouncedRef("");
const searches = ref(0);
watch(typed, (text) => { query.value = text; }); // every key press goes in...
watch(query, () => searches.value++); // ...but this runs after a pause // stands in for an API call
const results = computed(() =>
STATIONS.filter((s) => s.toLowerCase().includes(query.value.toLowerCase())));
return { typed, query, searches, results };
}
}).mount("#app");
</script>
</body>
</html>Type "jn" quickly: the counter goes up once, not twice. The typed ref keeps the text box responsive while query waits.
effectScope: stop many effects at once
Inside a component, Vue stops every computed() and watch() automatically when the component unmounts. Outside a component (in a plain module, a store or a test) nobody does that for you. effectScope() collects every effect created inside scope.run() so that one scope.stop() disposes all of them:
import { effectScope, ref, watch, computed, onScopeDispose } from "vue";
const scope = effectScope();
scope.run(() => {
const orders = ref([]);
const total = computed(() => orders.value.reduce((sum, o) => sum + o.amount, 0));
watch(total, (t) => console.log("Total is now", t));
const timer = setInterval(() => orders.value = [...orders.value, { amount: 99 }], 1000);
onScopeDispose(() => clearInterval(timer)); // runs when the scope stops
});
// later, for example when the user logs out:
scope.stop(); // the computed, the watcher and the interval are all goneonScopeDispose() is the scope-level version of onUnmounted(). Because a component's setup() also runs inside a scope, a composable that cleans up with onScopeDispose() works both inside components and inside a manual scope. Pinia uses effect scopes internally to manage each store.
Common mistakes
- Destructuring
reactive()objects or props into plain variables and expecting them to update. UsetoRefs(),toRef()or a getter. - Reading reactive data after
awaitinwatchEffect(). Those reads are not tracked. Read what you need before the firstawait, or usewatch()with explicit sources. - Changing a
shallowRef()in place and wondering why the page does not update. Assign a new value or calltriggerRef(). - Putting library objects (charts, maps, editors) in
ref(). Wrap them inmarkRaw()or keep them in a plain variable. - Creating watchers in plain modules without a scope. They live forever. Use
effectScope()and stop it.
Key points
- Vue tracks reads during an effect and re-runs the effect when those properties are written.
- Copying a value out of a proxy breaks the link;
toRefs(),toRef()and getters keep it. shallowRef()andshallowReactive()track only the top level; usetriggerRef()or replace the value.markRaw()keeps objects out of the reactivity system;toRaw()returns the original object.customRef()controls tracking and triggering, perfect for debounced values.effectScope()withonScopeDispose()cleans up groups of effects outside components.
Exercise
Write useThrottledRef(initial, wait) with customRef(). Unlike debounce, a throttled ref should trigger at most once every wait milliseconds while the user keeps typing, and always deliver the last value at the end. Test it with a text box for a delivery address in Kochi and a counter that shows how many times the value reached the rest of the app.
Show solution
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Throttled ref</title>
<script src="https://cdn.jsdelivr.net/npm/vue@3.5.44/dist/vue.global.prod.js"></script>
</head>
<body>
<div id="app">
<input v-model="address" placeholder="Delivery address in Kochi">
<p>Updates delivered: {{ updates }} (last: "{{ address }}")</p>
</div>
<script>
const { createApp, ref, customRef, watch } = Vue;
function useThrottledRef(initial, wait = 500) {
let value = initial;
let last = 0;
let timer = null;
return customRef((track, trigger) => ({
get() { track(); return value; },
set(v) {
value = v;
const now = Date.now();
if (now - last >= wait) {
last = now;
trigger();
} else if (!timer) {
timer = setTimeout(() => { timer = null; last = Date.now(); trigger(); }, wait - (now - last));
}
}
}));
}
createApp({
setup() {
const address = useThrottledRef("", 500);
const updates = ref(0);
watch(address, () => updates.value++);
return { address, updates };
}
}).mount("#app");
</script>
</body>
</html>The first change triggers at once; changes inside the wait window schedule one delayed trigger, so the final text always arrives.
Quick Quiz
1. Why does const { runs } = reactive({ runs: 10 }) not update the page later?
Reactivity lives on the proxy; toRefs() keeps a link to each property.
2. You change rows.value[0].amount where rows is a shallowRef(). What happens?
shallowRef() only tracks assignments to .value.
3. In customRef((track, trigger) => ...), where do you normally call track()?
track() in the getter records which effects depend on the ref; trigger() notifies them.
4. What does scope.stop() do for an effectScope()?
Effect scopes group effects so they can be cleaned up together.
Learn Vue.js with a mentor
Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
