Reactivity and template syntax

ref versus reactive, computed versus watch, and the template directives you will use in every component.

Declaring reactive state

import { ref, reactive, computed } from 'vue'

const count = ref(0)            // primitive: read and write with .value
const user = reactive({ name: 'Ada', tags: [] })   // object: properties are tracked directly

const double = computed(() => count.value * 2)     // cached derived value

count.value += 1
user.name = 'Grace'             // no .value for reactive objects
  • ref holds any value and always needs .value in script — but templates unwrap it automatically, so you write {{ count }}.
  • reactive only works with objects and arrays, and it is shallow when destructured: pulling const { name } = user out of it severs the reactive link.
  • Use toRefs(user) when you genuinely need to destructure and stay reactive.
  • computed caches on its dependencies; a plain function inside the template re-runs on every render.

Template directives

<p>{{ user.name }}</p>

<img :src="avatarUrl" :alt="user.name + ' avatar'">
<button @click="count++" @keyup.enter="submit">Add</button>
<input v-model="user.name">            <!-- two-way binding -->

<p v-if="count > 0">positive</p>
<p v-else-if="count < 0">negative</p>
<p v-else>zero</p>

<ul>
  <li v-for="tag in user.tags" :key="tag">{{ tag }}</li>
</ul>
SyntaxLong formPurpose
{{ x }}Interpolate text (escaped, safe)
:hrefv-bind:hrefBind an attribute or prop
@clickv-on:clickListen to an event
v-modelTwo-way binding on inputs and components
v-if / v-showRemove from the DOM / toggle display
v-forRender a list; always pass :key

Modifiers change a directive's behaviour: @submit.prevent stops the default action, @click.stop stops propagation, @click.once detaches after the first call, and v-model.lazy syncs on change instead of on every keystroke.

watch versus computed

import { ref, computed, watch, watchEffect } from 'vue'

const query = ref('')
const results = ref([])

const trimmed = computed(() => query.value.trim())   // derive

watch(trimmed, async (value, oldValue) => {          // side effect
  if (!value) { results.value = []; return }
  results.value = await search(value)
})
  • computed: pure, cached, no side effects. If you find yourself assigning inside one, you wanted a watch.
  • watch: runs a side effect after a source changes; accepts a ref, a getter, or an array of sources.
  • watchEffect: runs immediately and re-runs whenever any reactive value it touched changes — convenient, but less explicit about what it tracks.
  • { deep: true } is needed to react to mutations inside a nested object or array.
  • Clear timers and abort requests in the callback's cleanup function so a slow request cannot overwrite newer results.
watch(query, async (value, _old, onCleanup) => {
  const controller = new AbortController()
  onCleanup(() => controller.abort())
  results.value = await search(value, { signal: controller.signal })
})
⚠️
Never mutate state inside a computed, and never render v-html with untrusted input — it inserts raw HTML and is the standard XSS entry point in a Vue app.

FAQ

ref or reactive?
Default to ref: it works for primitives and objects alike, survives destructuring, and passes cleanly to composables. Reach for reactive when you have a cohesive record you never destructure.
Why is my computed not updating?
It only tracks reactive sources read during evaluation. If it depends on something non-reactive — a plain variable, a DOM property, a date read once — it will not recompute.

Components, props and events Arrays and iteration

Last refreshed 2026-09-18.