EthicsComputerCourses
Join training +91 90532 10052

Advanced Reactivity

Vue.js · Lesson 22 of 41 Intermediate 7 min read Updated

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:

  1. 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.
  2. Only reads that happen while the effect runs are tracked. In watchEffect(async () => { ... }), properties read after the first await are 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:

HTMLTry it yourself
<!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():

HTMLTry it yourself
<!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.

  1. Create the factory. customRef((track, trigger) => ({ get, set })) gives you two functions: track() says "an effect depends on me", trigger() says "I changed".
  2. Call track() in get() and return the stored value, so computed properties and templates depend on the ref.
  3. Delay trigger() in set(). Store the new value, clear any pending timer and start a new one that calls trigger() when it fires.
  4. Wrap it in a function useDebouncedRef(initial, delay) so it can be reused like any composable.
HTMLTry it yourself
<!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:

JavaScript
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 gone

onScopeDispose() 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. Use toRefs(), toRef() or a getter.
  • Reading reactive data after await in watchEffect(). Those reads are not tracked. Read what you need before the first await, or use watch() with explicit sources.
  • Changing a shallowRef() in place and wondering why the page does not update. Assign a new value or call triggerRef().
  • Putting library objects (charts, maps, editors) in ref(). Wrap them in markRaw() 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() and shallowReactive() track only the top level; use triggerRef() 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() with onScopeDispose() 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
HTML
<!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?

2. You change rows.value[0].amount where rows is a shallowRef(). What happens?

3. In customRef((track, trigger) => ...), where do you normally call track()?

4. What does scope.stop() do for an effectScope()?

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