Template syntax and directives — projecting state onto the screen
What you write in <template> is not plain HTML. It adds notation for inserting values, plus directives that control the attributes and visibility of elements. All of it exists to project state onto the screen, letting you write declarations instead of touching the DOM imperatively.
This chapter sorts that notation by what it is for. The key on v-for gets particular attention, because getting it wrong still looks correct on screen while breaking interaction, so it is worth digging into what the option is actually for.
What you'll learn in this chapter
- Write value interpolation and attribute binding, choosing between them by purpose
- Know the range Vue escapes automatically and the routes that fall outside it
- Choose between
v-ifandv-showbased on where the cost falls - Explain what
keydoes, and why you must not pass it an index
Putting values on screen
Use mustache syntax for text, and v-bind for attributes.
<script setup lang="ts">
const url = 'https://vuejs.org'
const label = 'Official site'
const isExternal = true
</script>
<template>
<!-- in a text position, insert with double braces -->
<p>{{ label }}</p>
<!-- in an attribute position, use v-bind -->
<a v-bind:href="url">{{ label }}</a>
<!-- the colon-only shorthand; this is what you use in practice -->
<a :href="url" :target="isExternal ? '_blank' : undefined">{{ label }}</a>
</template>
Mustache syntax cannot go in an attribute position. Conversely, v-bind cannot go in a text position. The split is that the notation changes with where you are inserting.
What you can write inside is an expression. Statements are not allowed, so if and for cannot go there as-is. Ternaries and function calls are fine.
Making the attribute name itself dynamic
Wrap it in square brackets and the attribute name can be decided by an expression.
<template>
<!-- the value of attributeName becomes the attribute name -->
<a :[attributeName]="url">Link</a>
<!-- event names work the same way -->
<button @[eventName]="handle">Run</button>
</template>
There are three constraints. First, the expression must evaluate to a string or null. Passing null removes the binding itself, and any other non-string produces a warning. Second, you cannot put whitespace inside the brackets. :[ 'data-' + name ] is a compile error, so if you need to build the name up, move it into a computed. Third, when you write the template directly in HTML rather than in a .vue file, you cannot use uppercase in attribute names, because the browser lowercases them.
The situations that call for it are limited. It is a tool for writing generic components whose attribute name is decided at runtime; normally you just write :href.
What escaping protects
Vue escapes automatically for both text interpolation and attribute binding. It goes through the browser's standard APIs (textContent and setAttribute), so characters that would break the HTML structure are treated as characters.
<script setup lang="ts">
// assume both are strings entered by a user
const htmlLike = '<img src=x onerror="alert(1)">'
const attrInjection = '" onclick="alert(1)'
</script>
<template>
<!-- not interpreted as a tag; displayed as a string -->
<p>{{ htmlLike }}</p>
<!-- the attack of closing the quote and adding another attribute does not get through -->
<h1 :title="attrInjection">Title</h1>
</template>
What escaping prevents is "a string being interpreted as HTML structure". Where the value itself can be executed, escaping does nothing. Here are the main routes.
v-html interprets the string as HTML
<template>
<!-- only pass content whose origin you can vouch for -->
<div v-html="trustedHtml"></div>
</template>
It interprets the string you pass as HTML, so passing untrusted content runs arbitrary scripts. Vue's template syntax is not processed inside it, so any {{ }} shows up as plain characters.
A URL can be executed as the value itself
<template>
<!-- if url starts with javascript:, clicking runs it -->
<a :href="url">Link</a>
</template>
A string like javascript:alert(1) contains no HTML metacharacters, so there is nothing to escape. It is set as the attribute value as-is and runs on click. The official documentation states that user-provided URLs should be sanitised on the backend before they are saved to the database. Its position is that if you are sanitising URLs on the frontend, you already have a problem1.
style and events are routes too
Pass user input to :style and CSS can be injected that layers a transparent element over a button. That is the entry point for an attack that hijacks the display.
Dynamic attribute names add another route
The :[attributeName] mentioned earlier has a pitfall. When the attribute name comes out as an all-lowercase on-style name such as onclick, Vue sets it as an attribute rather than an event listener. That becomes setAttribute('onclick', '...'), so the value runs as JavaScript.
Vue treats a name as an event listener only when the third character is not lowercase, as in onClick. Because of that gap in the check, avoid designs that build attribute names out of user input.
The criterion for judgement is "can I vouch for where this value came from?" Do not pass a value you cannot vouch for into a position that will be interpreted as HTML, a URL, CSS, or an event. Treat the attribute name itself the same way.
Binding class and style
class and style get special treatment: besides strings, you can pass objects and arrays.
<script setup lang="ts">
import { computed, ref } from 'vue'
const isActive = ref(true)
const hasError = ref(false)
// move it into a computed once the conditions multiply
const badgeClass = computed(() => ({
'badge--active': isActive.value,
'badge--error': hasError.value,
}))
</script>
<template>
<!-- object syntax: a boolean decides whether the class is applied -->
<span :class="{ 'badge--active': isActive }">Object syntax</span>
<!-- passing a computed moves the conditions out of the template -->
<span :class="badgeClass">Passing a computed</span>
<!-- can sit alongside a static class; both are applied -->
<span class="badge" :class="badgeClass">Alongside a static class</span>
<!-- style can be written in camelCase -->
<p :style="{ color: 'navy', fontSize: '16px' }">Inline style</p>
</template>
Writing a static class attribute and :class at the same time does not cancel either out. Vue merges both.
Once you have more than two conditions, moving them into a computed makes the template easier to read. A template can only hold one expression, so cramming complicated conditions in makes it unreadable in the end.
Conditional rendering
v-if adds and removes the element itself
<script setup lang="ts">
const score = 75
</script>
<template>
<p v-if="score >= 80">Excellent</p>
<p v-else-if="score >= 60">Pass</p>
<p v-else>Fail</p>
</template>
v-else and v-else-if have to sit immediately after the element carrying v-if. Putting another element in between severs the connection.
To add and remove several elements as a unit, put the directive on <template>. That element is not rendered itself, so no extra div is left in the DOM.
<template>
<template v-if="isLoggedIn">
<h1>Welcome</h1>
<button @click="logout">Log out</button>
</template>
<template v-else>
<h1>Guest</h1>
<button @click="login">Log in</button>
</template>
</template>
v-show hides with CSS
<template>
<p v-show="isVisible">Toggled with display</p>
</template>
The element is always rendered and only the display property is switched. It cannot go on <template>, and it cannot be combined with v-else.
Which to choose
The costs fall in opposite places.
| Aspect | v-if | v-show |
|---|---|---|
| Cost of initial render | Low. Nothing is rendered when the condition is false | High. It renders regardless of the condition |
| Cost of toggling | High. The element is destroyed and recreated | Low. Only the CSS changes |
| First time through | Defers rendering until the condition first becomes true | Renders from the start |
<template> | Usable | Not usable |
v-else | Usable | Not usable |
| Suits | Conditions that rarely change | Frequent toggling |
v-if is a "real" conditional. Every toggle destroys and recreates event listeners and child components, so state is reinitialised too. The reason previous input does not survive when you switch tabs is that this destruction runs.
v-show hides the element while leaving it in place, so the state inside is preserved too. For something toggled repeatedly, such as a collapsible panel, this is the better fit.
List rendering
v-for expands an array.
<script setup lang="ts">
import { ref } from 'vue'
type Fruit = {
id: number
name: string
}
const fruits = ref<Fruit[]>([
{ id: 1, name: 'Apple' },
{ id: 2, name: 'Banana' },
])
let nextId = 3
const addFruit = () => {
// settle the id first, then use it for both
const id = nextId
nextId += 1
fruits.value.push({ id, name: `Fruit ${id}` })
}
</script>
<template>
<ul>
<li v-for="fruit in fruits" :key="fruit.id">{{ fruit.name }}</li>
</ul>
<!-- the index is available too -->
<ul>
<li v-for="(fruit, index) in fruits" :key="fruit.id">
{{ index }}: {{ fruit.name }}
</li>
</ul>
<button @click="addFruit">Add</button>
</template>
What key is doing
key is the marker Vue uses to decide which element from last time corresponds to which one this time. Whether you provide it changes the update strategy.
Without a key, Vue tries to minimise element movement, reusing the element in the same position and rewriting only its contents. It rewrites the contents of the first li, then the contents of the second li, and so on. The elements themselves are not recreated, so for a list of nothing but text the result comes out correct.
With a key, Vue looks at the key to decide which element from last time corresponds to which one now. Elements whose keys match are reused rather than rebuilt, and elements whose keys are gone are destroyed. Since the element is reused, the state it holds survives too.
You cannot see this difference by looking at the display. It breaks when the element holds state.
What happens when you use the index as key
<template>
<ul>
<!-- the index points at a different item once the order changes -->
<li v-for="(fruit, index) in fruits" :key="index">
<input type="text" />
{{ fruit.name }}
</li>
</ul>
</template>
Say each row has an input field. You type "sweet" into the apple row, then add an item at the top, and the apple drops to second place. But because key is the index, the apple's key changes from 0 to 1. From Vue's point of view, "the element with key 0 is still there with different contents" and "the element with key 1 is still there with different contents", so the input fields stay where they are. The result is that "sweet" remains attached to a different fruit's row.
Had you passed fruit.id as the key, the apple's key would not have changed. Vue reuses the element with the matching key as-is, so the apple's li is not rebuilt. The new element is inserted before it, which puts the apple in second position, and the input field comes along attached to that element.
There are two conditions for the value you pass to key: it must be unique among siblings, and it must be a string or a number (the specification also allows symbols). Creating a new object each time and passing that means the previous and current values are judged different, and nothing gets reused.
Summary
- Use double braces in text positions and
v-bind(shorthand:) in attribute positions. The position determines the notation - Only expressions can go inside. Statements cannot
- Attribute names can be made dynamic, but the expression must evaluate to a string or
null.nullremoves the binding - Vue escapes text and attributes automatically. What that protects, though, is only "being interpreted as HTML structure".
v-html, URLs, CSS, events, and dynamic attribute names remain as routes. Ajavascript:URL contains nothing to escape so it passes straight through, and a lowercaseonclickis set as an attribute classandstyleaccept objects and arrays. Move the conditions into acomputedonce they multiplyv-ifis cheap to render initially and expensive to toggle;v-showis the reverse.v-showcannot be used with<template>orv-elsekeyis the marker that decides the correspondence between last time's elements and this time's. Without a key the correspondence is by position; with a key it is by key. Either way elements are reused, so the only difference is what the correspondence is based on- Passing a position (index) as the key makes it behave the same as having no key at all: the state on the element does not follow the item
- Using the index as
keymisaligns elements and contents once the order changes. The display alone will not reveal it; it surfaces through element-side state such as input fields and focus
- Vue — Template Syntax — interpolation, directives, and the constraints on dynamic arguments
- Vue — Conditional Rendering — comparing
v-ifandv-show - Vue — List Rendering —
keyand the in-place patch explanation - Vue — Security — the range of automatic escaping
- React guide — Conditional rendering and lists — how the React side handles the same problem
What to read next
- Events and form input — how input from the screen gets back into state
- Computed properties and watchers — the details of
computed, which moves conditions out of the template