Where Templates Trip People Up
The template mistakes that cost an afternoon: v-if with v-for, the template wrapper, and expressions doing too much.
v-if and v-for on one element
This is the common one. v-if has the higher priority, so it is evaluated before the loop exists, and the loop variable is not in scope: <li v-for="t in todos" v-if="!t.done"> throws because t is undefined there. Put the loop on a <template> and the condition on the element inside, or filter the list in script before you render it. The second is usually better, because the loop then only produces rows you intend to show.
template renders nothing
<template> inside a component's template is a grouping tag. It accepts v-if, v-for with its :key, and v-slot, and it emits no DOM element. That makes it the fix whenever a wrapper div would be invalid HTML or break a grid or a table. It does not accept v-show, because there is no element to set display on. Do not confuse it with the single outer <template> block of a .vue file, which is a different thing with the same name.
Expressions that do too much
A template expression is one expression, evaluated on every update of the component. Keep it side-effect free: no assignments, no requests, no array sorting. A function called from a template must be cheap, and anything longer than a line belongs in script where you can name and test it.
Example
<!-- Inbox.vue -->
<script setup lang="ts">
import { ref } from 'vue'
interface Message {
id: string
body: string
read: boolean
}
const messages = ref<Message[]>([
{ id: 'a1', body: 'Build finished', read: false },
{ id: 'a2', body: 'Deploy finished', read: true },
{ id: 'a3', body: 'Nightly tests passed', read: false }
])
const showRead = ref(false)
</script>
<template>
<section class="inbox">
<button type="button" @click="showRead = !showRead">
{{ showRead ? 'Hide read' : 'Show read' }}
</button>
<ul>
<!-- the loop is on the wrapper, the condition on the element inside -->
<template v-for="message in messages" :key="message.id">
<li v-if="showRead || !message.read" :class="{ read: message.read }">
{{ message.body }}
</li>
</template>
</ul>
</section>
</template>
<style scoped>
.inbox {
display: grid;
gap: 0.5rem;
}
.read {
color: #64748b;
}
</style>When to use it
- Fix a console error about an undefined loop variable in a filtered list.
- Repeat table rows or definition pairs without inserting an invalid wrapper element.
- Track down a list whose inputs keep the wrong values after a sort.
- Explain in review why a sort or an assignment does not belong in a template expression.
More examples
The broken version, and why
Keep this shape in mind so you recognise the error message. The first list fails because v-if is resolved before v-for on the same element, so the condition references a variable the loop has not created; the browser console reports message as undefined. The second compiles and looks fine until the array is sorted or an item is removed: Vue matches old and new nodes by key, and the keys 0, 1, 2 still match, so it reuses each node for a different message and any state inside, such as a focused input, stays at that position.
<!-- BrokenInbox.vue -->
<script setup lang="ts">
interface Message {
id: string
body: string
read: boolean
}
const messages: Message[] = [
{ id: 'a1', body: 'Build finished', read: false }
]
</script>
<template>
<ul>
<!-- WRONG: v-if runs first, so message does not exist yet -->
<li v-for="message in messages" v-if="!message.read" :key="message.id">
{{ message.body }}
</li>
</ul>
<!-- WRONG for a different reason: index keys break on reorder -->
<ul>
<li v-for="(message, index) in messages" :key="index">
{{ message.body }}
</li>
</ul>
</template>An expression with a side effect
The first line is the trap. It reads like a harmless display expression, but sort mutates names, so your source order is destroyed and the mutation happens again on every update. The same applies to reverse, splice and any assignment inside braces. The second line calls a named function that copies before sorting, which is testable and side-effect free. Both still run on every update of the component, which is the argument for caching the result in a computed property once you reach reactivity.
<!-- SortedNames.vue -->
<script setup lang="ts">
const names = ['Zoe', 'Ada', 'Grace']
function sortedNames(values: string[]): string[] {
// copy first; sort mutates in place
return [...values].sort((a, b) => a.localeCompare(b))
}
</script>
<template>
<!-- WRONG: sorts the source array every time the component updates -->
<p>{{ names.sort().join(', ') }}</p>
<!-- right: the helper copies, so the data is untouched -->
<p>{{ sortedNames(names).join(', ') }}</p>
</template>Only component scope and an allowlist of globals
Template expressions are compiled against the component's own scope plus a short allowlist of globals that includes Math and Date, so a property you or a third-party script attached to window is simply undefined in a template. The fix is the first two lines: read the global in script and expose it as a const, which also gives you one place to type it and to swap it for a build-time value. An app-wide alternative is app.config.globalProperties, covered when the app instance is introduced.
<!-- ScopeDemo.vue -->
<script setup lang="ts">
// a global set elsewhere in the page, for example by a third-party script
declare const APP_BUILD: string
// bring it into component scope so the template can see it
const build = APP_BUILD
const now = new Date('2026-01-01T00:00:00Z')
</script>
<template>
<!-- works: Math and Date are on Vue's allowlist -->
<p>{{ Math.round(1.5) }} / {{ now.getUTCFullYear() }}</p>
<!-- works: the global was assigned to a local const above -->
<p>Build {{ build }}</p>
<!-- would not work: window.APP_BUILD is not visible to a template -->
</template>
Discussion