Skip to main content

๐Ÿ”— Template Syntax and Reactivity

Templates are where Vue's magic becomes visible: you write HTML that describes your UI, and Vue keeps it synchronized with your data automatically. This lesson unpacks how interpolation, expressions, class/style binding, computed properties, and watchers work โ€” and how the reactivity engine ties them all together.

๐ŸŽฏ Learning Objectives

By the end of this lesson, you will be able to:

  • Use text interpolation and JavaScript expressions safely in templates
  • Bind classes and styles dynamically with object and array syntax
  • Explain how Vue 3's Proxy-based reactivity tracks and triggers updates
  • Write and choose between computed properties and methods
  • Use watchers for side effects and know when to prefer them over computed values

Estimated Time: 40โ€“50 minutes  โ€ข  Difficulty: Intermediate

Hands-on: Build a reactive product search with a debounced watcher and computed filtering.

In This Lesson

Text Interpolation

The most basic binding is the double-curly "mustache" syntax. Vue replaces the tag with the value and โ€” crucially โ€” keeps it updated whenever that value changes.

<span>Message: {{ msg }}</span>
๐Ÿ“ Analogy: A Vue template is a smart blueprint. A plain HTML page shows a fixed structure; a Vue template shows how the structure changes with the data โ€” like a blueprint that redraws itself as the number of occupants changes.

Text is escaped by default

Mustaches always render text, so any HTML in the value is shown literally โ€” safe from cross-site scripting. To render real HTML you must opt in with v-html, and only for trusted content.

SyntaxGiven value = "<b>Hi</b>"Safe?
{{ value }}Shows the literal text <b>Hi</b>โœ… Yes
v-html="value"Renders Hi in boldโš ๏ธ Only for trusted content

โš ๏ธ Security first

Default to mustaches. Never feed user-provided strings into v-html โ€” an attacker could inject a <script> or malicious event handler and run code in your users' browsers.

JavaScript Expressions

Bindings accept full JavaScript expressions, not just property names. Vue evaluates them in the component's scope.

{{ user.name }}                              <!-- property access -->
{{ count * 2 }}                              <!-- arithmetic -->
{{ isActive ? 'Active' : 'Inactive' }}      <!-- ternary -->
{{ message.split('').reverse().join('') }}  <!-- method chains -->
{{ `Hello, ${user.name}!` }}                <!-- template literal -->

๐Ÿ’ก Expressions, not statements

Each binding must be a single expression. You can't put an if block, a loop, or a variable declaration inside mustaches โ€” those are statements. Reach for a ternary or a computed property instead.

Just because you can write complex logic in a template doesn't mean you should. Long expressions hurt readability and can't be reused. Move anything non-trivial into a computed property:

<!-- Avoid: heavy logic in the template -->
<div>{{ items.filter(i => i.visible).map(i => i.name).join(', ') }}</div>

<!-- Prefer: a named computed value -->
<div>{{ visibleNames }}</div>
import { computed } from 'vue'

const visibleNames = computed(() =>
  items.value.filter(i => i.visible).map(i => i.name).join(', ')
)

Dynamic Class & Style Binding

Binding class and style is so common that Vue gives them special superpowers โ€” you can pass objects and arrays, not just strings.

Class binding

<!-- Object syntax: keys are class names, values decide on/off -->
<div :class="{ active: isActive, 'text-danger': hasError }"></div>

<!-- Array syntax: a list of class names -->
<div :class="[activeClass, errorClass]"></div>

<!-- Combine them, and mix with a static class attribute -->
<div class="card" :class="[baseClass, { active: isActive }]"></div>

Style binding

<!-- Object syntax; camelCase or 'kebab-case' keys both work -->
<div :style="{ color: activeColor, fontSize: fontSize + 'px' }"></div>

<!-- Bind a whole style object -->
<div :style="styleObject"></div>

<!-- Array of style objects merges them -->
<div :style="[baseStyles, overrideStyles]"></div>

Vue automatically adds vendor prefixes where needed. It's the cleanest way to reflect UI states โ€” active, loading, disabled, error โ€” straight from your reactive data.

Reactive state drives class and style bindings A reactive state box feeds an isActive flag into a class binding and a color value into a style binding, both of which update the rendered element. Reactive state isActive ยท color :class binding { active: isActive } :style binding { color } Rendered element
Figure 1 โ€” A single piece of reactive state can flow into both class and style bindings, and any change re-renders the element automatically.

How Reactivity Works

Everything above depends on Vue noticing when your data changes. In Vue 3 that job is done by JavaScript Proxies, which wrap your state and intercept reads and writes.

flowchart TD A[Plain object] -->|reactive| B[Reactive Proxy] B -->|read a property| C[Track dependency] B -->|write a property| D[Trigger update] C --> E[Dependency map] D --> E E -->|notify| F[Re-render affected DOM]

Conceptually, a reactive object is nothing more than a proxy that tracks who reads each property and triggers those readers when the property changes:

// A simplified sketch of Vue 3's reactivity
function reactive(obj) {
  return new Proxy(obj, {
    get(target, key) {
      track(target, key)       // remember who read this
      return target[key]
    },
    set(target, key, value) {
      target[key] = value
      trigger(target, key)     // notify everyone who read it
      return true
    }
  })
}
๐Ÿ  Analogy: Reactivity is like a smart home. Sensors note which rooms you enter (dependency tracking); when you nudge the thermostat (a write), the system responds only where it matters (re-rendering just the affected DOM).

๐Ÿ’ก Vue 3 fixed Vue 2's blind spots

Vue 2 used Object.defineProperty, which couldn't detect adding a brand-new property or setting an array element by index โ€” you needed Vue.set. Vue 3's Proxies handle those cases natively, so state.newProp = 1 and arr[0] = 'x' on a reactive object are properly tracked. One caveat remains: destructuring a reactive object breaks the connection โ€” use toRefs if you need to pull values out.

Computed Properties

A computed property derives a value from other reactive data. Its defining feature is caching: it only re-runs when one of its dependencies changes, and otherwise returns the memorized result.

import { ref, computed } from 'vue'

const firstName = ref('Grace')
const lastName = ref('Hopper')

const fullName = computed(() => `${firstName.value} ${lastName.value}`)
// In the template: {{ fullName }}

Computed vs method

You could write a method that returns the same string. The difference is caching: a method re-runs on every render, while a computed value re-runs only when its inputs change.

Computed propertyMethod
CachingYes โ€” based on dependenciesNo โ€” runs every render
Template use{{ fullName }}{{ getFullName() }}
Best forDeriving/transforming dataActions & event handlers

Writable computed

Computed values are read-only by default, but you can supply a getter and setter for two-way derived state:

const fullName = computed({
  get: () => `${firstName.value} ${lastName.value}`,
  set(value) {
    const parts = value.split(' ')
    firstName.value = parts[0]
    lastName.value = parts[parts.length - 1]
  }
})

fullName.value = 'Ada Lovelace' // updates firstName and lastName

Watchers

Where computed properties derive values, watchers run side effects in response to change โ€” fetching data, saving to storage, or kicking off an animation.

import { ref, watch } from 'vue'

const searchQuery = ref('')

watch(searchQuery, (newValue, oldValue) => {
  fetchSearchResults(newValue)
})

Options: deep & immediate

const user = reactive({ name: '', address: {} })

watch(
  () => user,
  () => console.log('user changed'),
  { deep: true, immediate: true } // watch nested props; run once on setup
)

watchEffect โ€” track dependencies automatically

When you don't need the old value and want to watch several sources at once, watchEffect runs immediately and re-runs whenever any reactive value it reads changes:

import { watchEffect } from 'vue'

watchEffect(() => {
  document.title = `${count.value} items โ€” ${searchQuery.value}`
})

๐Ÿ“– Computed or watcher?

Use a computed property when you're producing a value synchronously from other data. Use a watcher when a change should trigger an action with side effects โ€” an API call, a timer, writing to localStorage โ€” or when you need the previous value.

Worked Example: Reactive Search

This component pulls the whole lesson together: a debounced watcher on the search box, computed filtering and sorting, dynamic classes and styles, and conditional rendering โ€” all in modern <script setup>.

<script setup>
import { ref, computed, watch } from 'vue'

const searchQuery = ref('')
const debouncedQuery = ref('')
const sortBy = ref('name')
const onlySale = ref(false)

const products = ref([
  { id: 1, name: 'Laptop Pro',    price: 1299.99, onSale: false, inStock: true },
  { id: 2, name: 'Smartphone X',  price: 799.99,  onSale: true,  inStock: true },
  { id: 3, name: 'Headphones',    price: 149.99,  onSale: false, inStock: false },
  { id: 4, name: 'Smart Watch',   price: 249.99,  onSale: true,  inStock: true }
])

// Debounce the search box with a watcher
let timer
watch(searchQuery, (value) => {
  clearTimeout(timer)
  timer = setTimeout(() => { debouncedQuery.value = value }, 300)
})

// Computed: filter + sort, cached until inputs change
const filtered = computed(() => {
  let list = products.value

  if (debouncedQuery.value) {
    const q = debouncedQuery.value.toLowerCase()
    list = list.filter(p => p.name.toLowerCase().includes(q))
  }
  if (onlySale.value) list = list.filter(p => p.onSale)

  return [...list].sort((a, b) =>
    sortBy.value === 'price'
      ? a.price - b.price
      : a.name.localeCompare(b.name)
  )
})

const formatPrice = (n) => `$${n.toFixed(2)}`
</script>

<template>
  <h1>Product Store</h1>

  <input v-model="searchQuery" placeholder="Search products...">
  <select v-model="sortBy">
    <option value="name">Name (Aโ€“Z)</option>
    <option value="price">Price (low to high)</option>
  </select>
  <label><input type="checkbox" v-model="onlySale"> On sale only</label>

  <p v-if="filtered.length === 0">No products match your search.</p>

  <ul v-else>
    <li
      v-for="product in filtered"
      :key="product.id"
      :class="{ sale: product.onSale }">
      <strong>{{ product.name }}</strong> โ€” {{ formatPrice(product.price) }}
      <span :style="{ color: product.inStock ? 'green' : 'red' }">
        {{ product.inStock ? 'In stock' : 'Out of stock' }}
      </span>
    </li>
  </ul>

  <p>Showing {{ filtered.length }} of {{ products.length }} products</p>
</template>

<style scoped>
.sale { border-left: 3px solid #e44d26; padding-left: 0.5rem; }
</style>

What it demonstrates

Text interpolation, dynamic class and style binding, conditional and list rendering, two-way v-model, a computed filter/sort pipeline, and a debounced watcher โ€” the full reactivity toolkit in one screen.

Hands-on Exercise

๐Ÿ‹๏ธ Reactive Registration Form with Validation

Objective: Combine computed properties, class binding, and conditional rendering to give live validation feedback.

Requirements:

  1. Fields bound with v-model: name, email, and password.
  2. Computed booleans that validate each field (email format, password length โ‰ฅ 8).
  3. Use :class to mark each field valid or invalid, and v-if to show a specific error message.
  4. A submit button that is :disabled until every field is valid.
๐Ÿ’ก Hint

A single computed formValid can combine the individual checks: const formValid = computed(() => nameValid.value && emailValid.value && passwordValid.value). Bind it with :disabled="!formValid".

โœ… Sample solution (core logic)
<script setup>
import { ref, computed } from 'vue'

const name = ref('')
const email = ref('')
const password = ref('')

const nameValid = computed(() => name.value.trim().length > 0)
const emailValid = computed(() => /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email.value))
const passwordValid = computed(() => password.value.length >= 8)
const formValid = computed(() =>
  nameValid.value && emailValid.value && passwordValid.value
)

function submit() {
  alert(`Welcome, ${name.value}!`)
}
</script>

<template>
  <form @submit.prevent="submit">
    <input v-model="name" :class="{ invalid: !nameValid }" placeholder="Name">

    <input v-model="email" :class="{ invalid: !emailValid }" placeholder="Email">
    <p v-if="email && !emailValid" class="error">Enter a valid email.</p>

    <input v-model="password" type="password" :class="{ invalid: !passwordValid }" placeholder="Password">
    <p v-if="password && !passwordValid" class="error">At least 8 characters.</p>

    <button :disabled="!formValid">Register</button>
  </form>
</template>

<style scoped>
.invalid { border: 1px solid red; }
.error { color: red; font-size: 0.85rem; }
</style>

๐ŸŽฏ Quick Quiz

Question 1: What is the main advantage of a computed property over a method that returns the same value?

Question 2: You need to call an API 300ms after the user stops typing in a search box. Which tool fits best?

Question 3: How does Vue 3 make an object reactive under the hood?

Best Practices

โœ… Do

  • Keep template expressions short; push logic into computed properties.
  • Use computed for derived values, watchers for side effects.
  • Prefer object/array :class and :style syntax over string concatenation.
  • Default to escaped {{ }} interpolation for all user-facing text.

โš ๏ธ Don't

  • Don't run v-html on untrusted input โ€” it's an XSS vector.
  • Don't cram filtering/sorting chains into the template; a computed value is cached and reusable.
  • Don't destructure a reactive object and expect it to stay reactive โ€” use toRefs.

Summary & Quiz

๐ŸŽ‰ Key Takeaways

  • Mustache interpolation renders escaped text; v-html renders raw HTML and needs trusted input.
  • Bindings take full JavaScript expressions, but keep them simple and push logic to computed values.
  • Bind class and style with object and array syntax for clean, state-driven UI.
  • Vue 3 reactivity is Proxy-based: it tracks reads and triggers updates on writes.
  • Use computed for cached derived values and watchers for side effects.

๐Ÿ“š Further Reading

๐Ÿš€ What's Next?

You've mastered a single component's template and reactivity. Next we scale up to the Vue component system โ€” building reusable components, passing data with props, and communicating between them with events.

๐ŸŽ‰ Excellent!

Reactivity is the beating heart of Vue, and you now understand how it pumps.