๐ 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.
| Syntax | Given 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.
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.
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 property | Method | |
|---|---|---|
| Caching | Yes โ based on dependencies | No โ runs every render |
| Template use | {{ fullName }} | {{ getFullName() }} |
| Best for | Deriving/transforming data | Actions & 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:
- Fields bound with
v-model:name,email, andpassword. - Computed booleans that validate each field (email format, password length โฅ 8).
- Use
:classto mark each field valid or invalid, andv-ifto show a specific error message. - A submit button that is
:disableduntil 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
:classand:stylesyntax over string concatenation. - Default to escaped
{{ }}interpolation for all user-facing text.
โ ๏ธ Don't
- Don't run
v-htmlon 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
reactiveobject and expect it to stay reactive โ usetoRefs.
Summary & Quiz
๐ Key Takeaways
- Mustache interpolation renders escaped text;
v-htmlrenders raw HTML and needs trusted input. - Bindings take full JavaScript expressions, but keep them simple and push logic to computed values.
- Bind
classandstylewith 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
- Vue.js โ Template Syntax
- Vue.js โ Reactivity Fundamentals
- Vue.js โ Computed Properties
- Vue.js โ Watchers
๐ 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.