Debugging Reactivity

Use onTrack and onTrigger on watchers and computeds to see which dependencies were recorded and which write woke them.

Syntaxcomputed(getter, { onTrack(e) {}, onTrigger(e) {} })

Reactivity is dependency tracking. When an effect reads a ref or a property of a reactive object, Vue records that read; a later write to the same property re-runs the effect. So "it did not update" has only two causes: the read was never tracked, or the write never triggered. The debug options tell you which.

onTrack and onTrigger

computed() takes a second argument, an options object carrying onTrack and onTrigger. watch() and watchEffect() accept the same two options in their own options object. onTrack runs each time a dependency is recorded. onTrigger runs when one of those dependencies changes and the effect is queued.

Both receive a DebuggerEvent: target is the object that was touched, key is the property, and type is 'get', 'has' or 'iterate' for a track and 'set', 'add', 'delete' or 'clear' for a trigger. A trigger event also carries oldValue and newValue.

If a computed never recomputes, the key you expected will be missing from the onTrack output. That means the read happened outside the getter, or the value was copied out of a reactive object before the getter ran.

Render-level hooks

onRenderTracked and onRenderTriggered do the same job for a component's own render. A trigger with a key you did not expect means the template reads more than you thought.

What trips people up

All of these are development-only and are not called during server-side rendering. Prefer a debugger statement over a log: the call stack is what tells you which line performed the read.

Example

Example · vue
<script setup lang="ts">
import { computed, ref, watch } from 'vue'

type Line = { id: number; price: number }

const props = defineProps<{ taxRate: number }>()

const lines = ref<Line[]>([
  { id: 1, price: 1200 },
  { id: 2, price: 450 }
])

const net = computed(
  () => lines.value.reduce((sum, line) => sum + line.price, 0),
  {
    onTrack(event) {
      console.log('net tracked', String(event.key), event.type)
    },
    onTrigger(event) {
      console.log('net invalidated by', String(event.key), event.type)
    }
  }
)

const gross = computed(() => Math.round(net.value * (1 + props.taxRate)))

watch(gross, (value, oldValue) => {
  console.log('gross', oldValue, '->', value)
}, {
  onTrigger(event) {
    console.log('watcher woken by', String(event.key))
  }
})

function addLine(): void {
  lines.value.push({ id: lines.value.length + 1, price: 99 })
}
</script>

<template>
  <div>
    <p v-for="line in lines" :key="line.id">{{ line.price }}</p>
    <p>Gross: {{ gross }}</p>
    <button type="button" @click="addLine">Add line</button>
  </div>
</template>

<style scoped>
p {
  margin: 0;
}
</style>

When to use it

  • Work out why a computed keeps its old value after you mutated what you thought was its source.
  • Confirm which property a watcher reacted to when the callback fires more often than expected.
  • Trace a component that re-renders on every keystroke back to the template expression reading the input.

More examples

Finding out why a component re-rendered

Reach for these when a component redraws more often than the screen changes. onRenderTracked lists every value the template reads, which is the real dependency set; onRenderTriggered names the single write that caused this redraw, with the old and new values. Both are development-only and never run during SSR.

Example · vue
<script setup lang="ts">
import { onRenderTracked, onRenderTriggered, ref } from 'vue'

const query = ref('')
const hits = ref(0)

onRenderTracked((event) => {
  console.log('render depends on', String(event.key), event.type)
})

onRenderTriggered((event) => {
  console.log('re-render caused by', String(event.key), event.oldValue, event.newValue)
})

function record(): void {
  hits.value += 1
}
</script>

<template>
  <div>
    <input v-model="query" type="search" />
    <p>{{ hits }} hits for "{{ query }}"</p>
    <button type="button" @click="record">Record a hit</button>
  </div>
</template>

<style scoped>
div {
  display: grid;
}
</style>

A watch source that is not a source

A watch source must be a ref, a reactive object, or a getter function. Passing a property reads it once and hands watch a plain value, so nothing is ever tracked and onTrigger never fires. Wrapping the read in an arrow function makes the read happen inside the effect. flush: 'sync' is used here only to make the ordering printable; the default 'pre' runs the callback before the next render instead.

Example · ts
import { reactive, watch } from 'vue'

const state = reactive({ count: 0, label: 'clicks' })

// Wrong: watch receives the number 0, not something it can track.
// watch(state.count, (value) => console.log(value))

watch(
  () => state.count,
  (value, oldValue) => {
    console.log(`count ${oldValue} -> ${value}`)
  },
  {
    flush: 'sync',
    onTrigger(event) {
      console.log('trigger key:', String(event.key))
    }
  }
)

state.count = 1
// Output: trigger key: count
// Output: count 0 -> 1

Listing a watchEffect's real dependencies

onTrack prints one line per dependency the effect read, so the output is the dependency set in full. Here coupon never appears, which is the explanation for the bug report that changing the coupon does not update the total: the effect never reads it, so writing it cannot trigger anything. Use this when an effect ignores a change you were sure it watched.

Example · ts
// src/composables/useTotals.ts
import { reactive, watchEffect } from 'vue'

export function useTotals() {
  const cart = reactive({
    items: [{ price: 10 }, { price: 25 }],
    coupon: ''
  })

  watchEffect(
    () => {
      const total = cart.items.reduce((sum, item) => sum + item.price, 0)
      console.log('total', total)
    },
    {
      onTrack(event) {
        console.log('tracked:', String(event.key), event.type)
      }
    }
  )

  return { cart }
}

Discussion

  • Be the first to comment on this lesson.