On this page

Template syntax and directives: v-bind, v-on, v-if, v-for, v-model

14 min read TextCh. 1 — Vue Fundamentals

Template syntax overview

Vue's template syntax is a declarative, HTML-based language. The compiler transforms templates into optimized JavaScript render functions — you write familiar HTML with a thin layer of Vue-specific extensions. Every valid HTML document is also a valid Vue template.

The two fundamental building blocks are text interpolation and directives.

Text interpolation

Use double curly braces to render a reactive expression as text:

<p>{{ greeting }}</p>
<p>{{ user.name.toUpperCase() }}</p>
<p>Total: {{ price * quantity }}</p>

The expression is re-evaluated and the DOM is updated automatically whenever the referenced reactive data changes. Interpolation is text-only — it HTML-escapes the result, preventing XSS. To render raw HTML, use v-html (only with trusted content).

v-bind — binding attributes

v-bind binds a JavaScript expression to an HTML attribute. The shorthand is a single colon :

<!-- Long form -->
<img v-bind:src="imageUrl" v-bind:alt="imageAlt" />

<!-- Shorthand (preferred) -->
<img :src="imageUrl" :alt="imageAlt" />

<!-- Bind an object of attributes at once -->
<img v-bind="{ src: imageUrl, alt: imageAlt, width: 200 }" />

Dynamic class and style bindings

Class and style receive special treatment that allows objects and arrays:

<!-- Object syntax: key is class name, value is boolean -->
<div :class="{ active: isActive, 'text-danger': hasError }"></div>

<!-- Array syntax: mix static and dynamic classes -->
<div :class="['base-class', isActive ? 'active' : '', extraClass]"></div>

<!-- Style object -->
<div :style="{ color: brandColor, fontSize: `${size}px` }"></div>

<!-- Array of style objects (merged) -->
<div :style="[baseStyles, overrideStyles]"></div>

v-on — event handling

v-on attaches event listeners. The shorthand is @:

<!-- Inline handler -->
<button type="button" @click="count++">+</button>

<!-- Method reference (receives the Event object) -->
<button type="button" @click="handleClick">Click</button>

<!-- Method call with argument -->
<button type="button" @click="remove(item.id)">Delete</button>

<!-- Inline with $event for the native event -->
<input @change="handleChange($event.target.value)" />

Event modifiers

Vue's event modifiers prevent repetitive boilerplate:

<!-- Equivalent to event.preventDefault() -->
<form @submit.prevent="onSubmit">...</form>

<!-- Stop event propagation -->
<a @click.stop="doThis">...</a>

<!-- Chain modifiers -->
<a @click.stop.prevent="doThat">...</a>

<!-- Only fires once -->
<button type="button" @click.once="doOnce">...</button>

<!-- Key modifiers -->
<input @keyup.enter="submit" />
<input @keyup.esc="clear" />

v-if, v-else-if, v-else — conditional rendering

v-if conditionally renders an element. The element is completely removed from the DOM when the condition is falsy — event listeners and child components are destroyed and recreated.

<div v-if="status === 'loading'">Loading...</div>
<div v-else-if="status === 'error'">Error: {{ errorMessage }}</div>
<div v-else>Content loaded!</div>

Use <template> as an invisible wrapper when you need to conditionally group multiple elements without adding an extra DOM node:

<template v-if="isLoggedIn">
  <nav>...</nav>
  <aside>...</aside>
</template>

v-show — toggle visibility

v-show keeps the element in the DOM and toggles display: none. It has higher initial render cost but cheaper toggle cost than v-if:

<div v-show="isVisible">This is always in the DOM</div>

v-for — list rendering

v-for renders a list of items. Always provide a :key with a unique, stable value:

<!-- Array -->
<li v-for="item in items" :key="item.id">{{ item.name }}</li>

<!-- With index -->
<li v-for="(item, index) in items" :key="item.id">
  {{ index + 1 }}. {{ item.name }}
</li>

<!-- Object properties -->
<li v-for="(value, key, index) in myObject" :key="key">
  {{ index }}: {{ key }} = {{ value }}
</li>

<!-- Range -->
<span v-for="n in 5" :key="n">{{ n }}</span>

v-for + v-if together

When both are needed on the same element, wrap the list in a <template v-for> and apply v-if inside:

<!-- CORRECT: v-for on the wrapper, v-if on the item -->
<template v-for="user in users" :key="user.id">
  <li v-if="user.isActive">{{ user.name }}</li>
</template>

Never put v-if and v-for on the same element — v-if has higher priority in Vue 3 and the loop variable would not be in scope.

v-model — two-way binding

v-model creates a two-way binding between a form element and a reactive variable. It is syntactic sugar over :value + @input:

<!-- These two are equivalent -->
<input v-model="name" />
<input :value="name" @input="name = $event.target.value" />

v-model adapts to different input types:

Element Bound property Event
<input type="text"> value input
<input type="checkbox"> checked change
<input type="radio"> checked change
<select> value change
<textarea> value input

v-model modifiers

<!-- Trim whitespace automatically -->
<input v-model.trim="name" />

<!-- Cast to number -->
<input type="number" v-model.number="age" />

<!-- Update on change event instead of input (less frequent) -->
<input v-model.lazy="search" />

v-once and v-memo

For performance-critical paths, v-once renders the element once and never re-renders it:

<h1 v-once>{{ staticTitle }}</h1>

v-memo re-renders only when one of its dependency values changes — useful for large lists:

<div v-for="item in list" :key="item.id" v-memo="[item.selected]">
  <!-- Only re-renders when item.selected changes -->
  <ExpensiveComponent :item="item" />
</div>

Practice

  1. Build a search filter: Create a component with a text input (v-model) and a list rendered with v-for. Filter the list in a computed() property using the search term.
  2. Toggle form sections: Create a multi-step form using v-if/v-else-if/v-else to show different sections based on a currentStep ref.
  3. Dynamic class binding: Create a set of buttons where clicking one makes it "active". Use :class with an object to apply an active CSS class to the selected button.

In the next lesson, we will go deep into Vue's reactivity system — understanding ref(), reactive(), and toRefs() at a fundamental level.

Always use :key with v-for
The :key attribute is mandatory when using v-for. Use a stable, unique identifier (like an id) — never the loop index, which causes subtle bugs when items are reordered or removed.
Event modifier cheatsheet
Common v-on modifiers: .prevent (preventDefault), .stop (stopPropagation), .once (fire once), .self (only if event target is element itself), .key aliases like .enter, .esc, .space.
v-show vs v-if
v-if removes the element from the DOM entirely. v-show keeps it in the DOM but toggles display:none. Use v-show for elements that toggle frequently; use v-if when a section is unlikely to appear at all.
vue
<script setup lang="ts">
import { ref } from 'vue'

interface Todo {
  id: number
  text: string
  done: boolean
}

const newText = ref('')
const filter = ref<'all' | 'active' | 'done'>('all')

const todos = ref<Todo[]>([
  { id: 1, text: 'Learn Vue 3.5', done: true },
  { id: 2, text: 'Build a real project', done: false },
  { id: 3, text: 'Explore Pinia', done: false },
])

const filtered = computed(() => {
  if (filter.value === 'active') return todos.value.filter(t => !t.done)
  if (filter.value === 'done')   return todos.value.filter(t => t.done)
  return todos.value
})

function addTodo() {
  const text = newText.value.trim()
  if (!text) return
  todos.value.push({ id: Date.now(), text, done: false })
  newText.value = ''
}

function removeTodo(id: number) {
  todos.value = todos.value.filter(t => t.id !== id)
}
</script>

<template>
  <section>
    <!-- v-model: two-way binding -->
    <form @submit.prevent="addTodo">
      <input
        v-model="newText"
        type="text"
        placeholder="New task…"
        aria-label="New task"
      />
      <button type="submit">Add</button>
    </form>

    <!-- v-model on a select -->
    <select v-model="filter" aria-label="Filter tasks">
      <option value="all">All</option>
      <option value="active">Active</option>
      <option value="done">Done</option>
    </select>

    <!-- v-if / v-else -->
    <p v-if="filtered.length === 0">No tasks to show.</p>

    <!-- v-for with :key -->
    <ul v-else>
      <li v-for="todo in filtered" :key="todo.id">
        <!-- v-bind shorthand : -->
        <input
          type="checkbox"
          :id="`todo-${todo.id}`"
          v-model="todo.done"
        />
        <!-- dynamic class binding -->
        <label
          :for="`todo-${todo.id}`"
          :class="{ done: todo.done }"
        >{{ todo.text }}</label>
        <!-- v-on shorthand @ -->
        <button
          type="button"
          @click="removeTodo(todo.id)"
          aria-label="Remove task"
        >✕</button>
      </li>
    </ul>
  </section>
</template>

<style scoped>
label.done { text-decoration: line-through; color: #9ca3af; }
ul { list-style: none; padding: 0; }
li { display: flex; align-items: center; gap: 0.5rem; margin-block: 0.25rem; }
</style>