Skip to content
ExponaBLOG
VUE.JS & NUXT · DAY 008 / 100

Template syntax essentials: a practical Vue & Nuxt guide

Directives you use daily.

Expona Admin9 min read

Introduction

You're building a dynamic form that needs to show or hide fields based on user input, render a list of API results, and keep the UI synchronized with component state. These aren't edge cases—they're day-to-day Vue.js work. Understanding template syntax essentials: a practical Vue & Nuxt guide means mastering the directives that make this happen efficiently and clearly. This guide covers the core directives you'll reach for constantly: conditional rendering, list rendering, property binding, event handling, and two-way binding. We'll focus on production patterns, performance implications, and mistakes we've learned to avoid in real applications.

v-if and v-show: Conditional rendering

Both v-if and v-show render content conditionally, but they work very differently under the hood. Understanding when to use each is crucial for performance and correctness.

v-if removes the element entirely from the DOM when the condition is false. It's a true conditional that destroys and recreates the element (and its component lifecycle) each time the condition toggles. Use v-if when:

  • The condition is unlikely to change often (toggle rarely)
  • Component initialization is expensive but toggling is cheap
  • You need different branches with different component logic

v-show keeps the element in the DOM but hides it with display: none when false. It's cheaper to toggle but always renders the underlying component. Use v-show when:

  • The condition toggles frequently
  • You want to preserve component state between toggles
  • The element is lightweight and rendering cost is negligible

Real scenario: a dashboard with an analytics sidebar that users toggle on and off constantly. We use v-show:

typescript
<template>
  <div class="dashboard">
    <button @click="showAnalytics = !showAnalytics">
      Toggle Analytics
    </button>
    
    <!-- v-show: keeps DOM, toggles display -->
    <aside v-show="showAnalytics" class="analytics-sidebar">
      <ChartComponent :data="analyticsData" />
    </aside>
  </div>
</template>

<script setup>
import { ref } from 'vue'
import ChartComponent from './ChartComponent.vue'

const showAnalytics = ref(false)
const analyticsData = ref([])
</script>

Conversely, when displaying an authentication page or admin panel that users access once per session, v-if prevents unnecessary rendering:

typescript
<template>
  <div>
    <!-- v-if: true conditional, destroys when false -->
    <AdminPanel v-if="user?.role === 'admin'" />
    <UserDashboard v-else-if="user?.role === 'user'" />
    <LoginForm v-else />
  </div>
</template>

<script setup>
import { computed } from 'vue'
import { useUserStore } from '@/stores/user'

const userStore = useUserStore()
const user = computed(() => userStore.currentUser)
</script>

Notice the v-else-if and v-else modifiers. These chain conditions cleanly and are more efficient than separate v-if statements because Vue short-circuits the evaluation.

v-for with :key: List rendering

Rendering lists is fundamental to most UIs, and v-for is the directive that makes it work. But getting the :key right separates working code from production issues.

v-for iterates over arrays or objects. The syntax is straightforward:

typescript
<template>
  <ul>
    <li v-for="item in items" :key="item.id">
      {{ item.name }}
    </li>
  </ul>
</template>

The :key attribute is not optional. It tells Vue which list item is which when the list changes. Without it (or with a non-unique key like array index), Vue reuses DOM nodes in ways that break component state, animation, and filtering.

Consider an e-commerce cart where each item has a quantity input:

typescript
<template>
  <div class="cart">
    <div v-for="product in cart" :key="product.id" class="cart-item">
      <span>{{ product.name }}</span>
      <input v-model.number="product.quantity" type="number" />
      <button @click="removeItem(product.id)">Remove</button>
    </div>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const cart = ref([
  { id: 1, name: 'Widget A', quantity: 2 },
  { id: 2, name: 'Widget B', quantity: 1 }
])

const removeItem = (id) => {
  cart.value = cart.value.filter(p => p.id !== id)
}
</script>

If we'd used :key="index" instead of :key="product.id", removing the first item would shift indices. Vue would reuse the DOM node for the second item, but its input's v-model would still reflect the old quantity. The user sees their quantity value jump to the next product—a silent, confusing bug.

For objects, iterate with the in-object syntax:

typescript
<div v-for="(value, key) in userProfile" :key="key">
  {{ key }}: {{ value }}
</div>

Always use a unique identifier (typically an ID from your backend). If your data lacks one, that's a data modeling problem, not a Vue problem—fix it upstream.

v-bind and v-on: Property and event binding

Dynamic attributes are the bridge between component logic and the DOM. v-bind sets any attribute reactively; v-on listens to events and calls methods.

v-bind maps a component property to an HTML attribute. The shorthand is ::

typescript
<!-- Full syntax -->
<button v-bind:disabled="isLoading">Submit</button>

<!-- Shorthand (preferred) -->
<button :disabled="isLoading">Submit</button>

<!-- Binding multiple attributes at once -->
<input v-bind="inputAttrs" />

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

const isLoading = ref(false)

const inputAttrs = computed(() => ({
  type: 'email',
  placeholder: 'Enter your email',
  required: true,
  'aria-label': 'User email'
}))
</script>

Use this pattern when building composable input wrappers. Spreading attributes lets child components inherit HTML properties from parents.

v-on (shorthand @) listens for events and runs a handler:

typescript
<template>
  <form @submit.prevent="submitForm">
    <input 
      v-model="email" 
      @input="validateEmail"
      @blur="markFieldTouched"
      @keydown.enter="submitForm"
    />
    <button @click="resetForm" type="button">Clear</button>
  </form>
</template>

<script setup>
import { ref } from 'vue'

const email = ref('')
const isTouched = ref(false)

const validateEmail = () => {
  // validation logic
}

const markFieldTouched = () => {
  isTouched.value = true
}

const submitForm = () => {
  // submit logic
}

const resetForm = () => {
  email.value = ''
  isTouched.value = false
}
</script>

Notice the event modifiers: .prevent (prevent default), .stop (stop propagation), .enter (keydown filter). These are syntactic sugar for common patterns. Also note that handlers can be inline expressions (for simple cases) or method references (for complex logic). Keep inline logic simple; extract anything over a line or two into a method.

For component-to-component communication, emit custom events from the child and listen with @ on the parent:

typescript
<!-- Parent -->
<template>
  <ProductFilter @filter-changed="handleFilterChange" />
</template>

<script setup>
const handleFilterChange = (filters) => {
  // update parent state
}
</script>

<!-- Child (ProductFilter.vue) -->
<script setup>
import { ref } from 'vue'

const emit = defineEmits(['filter-changed'])
const selectedCategory = ref('')

const applyFilter = () => {
  emit('filter-changed', { category: selectedCategory.value })
}
</script>

v-model: Two-way binding

v-model is syntactic sugar for :value + @input (or :checked + @change for checkboxes). It keeps form input synchronized with component state automatically.

typescript
<template>
  <input v-model="username" />
  <p>User will be: {{ username }}</p>
</template>

<script setup>
import { ref } from 'vue'

const username = ref('')
</script>

This is equivalent to:

typescript
<input :value="username" @input="username = $event.target.value" />

Vue handles the unwrapping. For checkboxes and radio buttons:

typescript
<template>
  <input v-model="agreed" type="checkbox" />
  <label>I agree to the terms</label>

  <select v-model="selectedOption">
    <option value="option-a">Option A</option>
    <option value="option-b">Option B</option>
  </select>
</template>

<script setup>
import { ref } from 'vue'

const agreed = ref(false)
const selectedOption = ref('option-a')
</script>

Use modifiers to enhance behavior:

typescript
<!-- .lazy: sync on change instead of input -->
<input v-model.lazy="searchQuery" />

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

<!-- .trim: strip whitespace -->
<input v-model.trim="message" />

<!-- Chain modifiers -->
<input v-model.number.lazy="price" />

For child components, create a prop and emit an update event. The parent uses v-model to bind:

typescript
<!-- Parent -->
<template>
  <CustomInput v-model="formData.email" />
</template>

<!-- CustomInput.vue -->
<template>
  <input 
    :value="modelValue" 
    @input="$emit('update:modelValue', $event.target.value)"
  />
</template>

<script setup>
defineProps(['modelValue'])
defineEmits(['update:modelValue'])
</script>

The naming convention update:modelValue is critical—it's what enables v-model shorthand on the parent. You can also bind multiple v-models by changing the prop name (e.g., v-model:title expects update:title emissions).

Common mistakes

  • Using array index as :key in v-for: If the list can be reordered, filtered, or items added/removed, indices change and component state breaks. Always use a stable, unique identifier.
  • Confusing v-if and v-show: Defaulting to v-show everywhere adds unnecessary DOM nodes. Use v-if for infrequent toggles and expensive components.
  • Mutating props directly instead of using v-model: If a child receives a prop and modifies it, the parent's state diverges. Use v-model or emit events properly.
  • Inline complex logic in event handlers: Templates should be readable. Move anything over a couple of operations into a method in <script setup>.
  • Forgetting event modifiers: Using @submit="submitForm" without .prevent causes a page reload. Know the modifiers: .prevent, .stop, .self, .capture, key filters like .enter, and mouse filters like .left.
  • Not using :key with dynamic component lists in Nuxt: Nuxt route params often affect list rendering. Without proper keys, users see stale data or broken state when navigating.

Key takeaways

  • Use v-if for true conditionals that rarely toggle; use v-show for frequent visibility toggles.
  • :key is mandatory for v-for lists—always use a unique identifier, never an index.
  • The : shorthand for v-bind and @ shorthand for v-on are standard; use them consistently.
  • v-model simplifies form binding; understand that it's syntactic sugar for :value/@input or :checked/@change.
  • Event modifiers (.prevent, .stop, .enter) prevent boilerplate; learn the common ones early.
  • Keep template logic simple; complex computations belong in <script setup> as methods or computed properties.
  • For parent-child communication, prefer v-model with proper update:propName emissions over direct prop mutation.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles